Skip to content

custody: role isolation behind [security].role_isolation (X-1, N-6, N-7, G-5; ADR-2122) - #12

Merged
jjohare merged 68 commits into
mainfrom
custody/integration
Oct 3, 2026
Merged

jjohare merged 68 commits into
mainfrom
custody/integration

Conversation

@jjohare

@jjohare jjohare commented Oct 3, 2026

Copy link
Copy Markdown

Integrates custody X-1 step 1. Everything ships with [security].role_isolation = false, and with the flag off the boot is today's. This PR is the owner's acceptance checklist for the rebuild. Do not turn the flag on in the same rebuild.

Merged (no-ff, authorship kept), in this order:

  1. custody/queen-adr2103
  2. custody/w0-bypasses
  3. custody/w1-role-accounts
  4. custody/w3-identity-port (carries W6a's rehearsal)
  5. custody/w7a-egress-register
  6. custody/w8-key-split
  7. custody/w5-producer-bake (landed after W3; the merge was clean, so the tree is the same as with the intended order)
  8. custody/w2-env-scrub
  9. custody/w9-sidecar-podkey

Integration resolutions (each is its own commit with the reason; see ADR-2122, "Integration resolutions" and "Correction to the integration disposition"):

  • One config name. /etc/supervisord.roles.conf everywhere.
  • One role table. The rehearsal, both halves, reads /etc/agentbox/role-accounts.json plus role-secrets.tsv plus W2's ROLE class. There is no derived numbering, and uid 965 stays reserved.
  • The identity port. [program:serve-identity] becomes user=ab-identity under the flag. The socket is /run/secrets/ab-identity-port/identity.sock, in group ab-identity-port (969: devuser, ab-identity, ab-gateway), with the directory ab-identity:969 0750 made by W1's delivery. It is not under /run/agentbox, because /run is a devuser-owned tmpfs. Key files keep W1's names.
  • W2's loader rule. The file wins. The bare variable is used only with the flag off. Under the flag a bare variable is logged ROLE-ISOLATION-LEAK and ignored.
  • ADR re-verification. 25 records re-verified at 3b5412963; every dated note is kept.

Acceptance for the owner's rebuild

1. Rebuild with the flag still off (host shell, tmux tab 6)

  • ./agentbox.sh rebuild, and the container comes up healthy. /ready is 200, which means bootstrap-seal wrote /run/agentbox/bootstrap.done.
  • grep -n role_isolation /etc/agentbox.toml shows role_isolation = false, or the line is absent.
  • supervisorctl status shows the same set as before the rebuild, plus:
    • serve-identity EXITED (expected; its log says role_isolation is off);
    • docker-read-proxy EXITED (expected).
  • getent passwd ab-identity gives uid 960.
  • getent group ab-identity-port gives 969:devuser,ab-identity,ab-gateway.
  • id devuser shows no group 0 and no 965.
  • findmnt /run/secrets is a tmpfs, uid=0, mode=711 on the mount. With the flag off the entrypoint hands it to devuser 0700, and nostr.key is there as before.
  • These files exist:
    • /etc/supervisord.roles.conf;
    • /etc/agentbox/role-secrets.tsv, which includes sockdir ab-identity-port 960 969 and a dir /var/lib/agentbox/events/sign row;
    • /etc/agentbox/role-accounts.json.
  • /opt/agentbox/sidestr/upstream exists when [sidechain].enabled, and the producer still makes blocks (W5).
  • The browser sidecar: ./agentbox.sh browsercontainer podkey status answers, and the Chrome profile is on the browsercontainer-profile volume (W9).

2. The rehearsal, flag off: both halves must say STAGED (exit 2)

  • Inside the container, as devuser: bash scripts/activation/role-isolation-rehearsal.sh. Expect exit 2, STAGED. Every row is not_applicable: flag off and records whether it would pass. The receipt goes to docs/estate-closeout/x1-rehearsal-<UTC>.json.
  • From the host shell, as root, never from inside the container: scripts/activation/role-isolation-rehearsal.host.sh --container agentbox. Expect exit 2. h-uid-collision must PASS (no host account in 960–979). h-socket records the host docker socket's mode and group. The receipt is x1-rehearsal-host-<UTC>.json.
  • Neither receipt contains a secret value. They hold names, paths, modes, uids and counts only.

3. Only after 1 and 2 are clean: the flag-on rehearsal (a separate, owner-scheduled reboot)

Set [security].role_isolation = true, restart, then run both halves again. Both must exit 0, PASS. Any FAIL means turning the flag off again; flag off is the rollback. What to look for:

  • (a) Secrets.
    • Every /run/secrets/<role>/ is 0500 and owned by the role, and its files are 0400. devuser gets EACCES.
    • /run/secrets/ab-identity-port is 960:969 0750. devuser can neither rename it nor create beside it (row … (rename/replace)).
    • At-rest copies are refused or absent. Expect FAIL here until W2's at-rest volume lockdown lands. The workspace treasury keys are devuser-readable today (ADR-2122, Status).
  • (b) Programs. Every role program is RUNNING as its role uid: nostr-relay and serve-identity as 960, nostr-gateway 961, nip98-proxy 962, sidestr-producer 964, sidestr-faucet 966. Each one's /proc/<pid>/environ is unreadable to devuser.
  • (c) Signing through the port.
    • A pods NIP-98 header verifies under agentbox-core's x-only key.
    • forum_event dry-run verifies under JunkieJarvis's key.
    • nip42_auth is accepted by 127.0.0.1:7777.
    • dm_unwrap, a foreign URL and an unknown op are refused.
    • There is one receipt line each under /var/lib/agentbox/events/sign/.
  • (d) The producer. It still makes a block within 2 × interval, running as ab-sidestr-dreamlab (964).
  • (e) Nothing ambient. No ROLE variable name (W2's 11) appears outside its role in any argv or environ, identity.env, the supervisor configs or /var/log. Any ROLE-ISOLATION-LEAK line in the logs is a finding.
  • (f) devuser's groups. devuser is not in group 0, cannot drive the docker socket and cannot sudo, and no role gid equals the socket's group. degraded:docker-socket and degraded:secrets-* are failures, not warnings.

The flag stays off until the owner has the PASS receipts for both halves. ADR-2122 stays proposed/inactive until then.

Gates at the merged head (d03defbea)

Nix cannot be evaluated in the container, so neither the image nor a boot has been exercised. Everything else passed:

Gate Result
agentbox-manifest role-accounts check 8 roles valid
agentbox-config-validate.js, both manifests valid
check-manifest-catalogue.js 78 gate paths resolve
adr-index-gen --check / --check-index 114 valid, README in sync
adr-ratchet.sh docs/adr origin/main HEAD and its self-test OK, 6/6
render-egress-register.js --check 31 routes
check-secret-not-in-env.sh PASS
env-secret-inventory.js --check 648 names; node test 13/13
RC-X1-01..06 14 / 16 / 3 / 17 / 6 / 30
role-isolation-supervisor 23
role-secrets-delivery 30
role-isolation-boot 22
role-isolation-rehearsal 23
sidechain-producer-gates / genesis / producer-baked 7 / 7 / 8
browsercontainer fetch-podkey / launch-args 16 / 7, one skip each
podkey-ctl, profile-volume pass
cargo test agentbox-manifest 163
cargo test nostr-pod-bridge 209
management-api npm run test:node 232 pass, 2 skipped (W8's owner-pending G-5 keys), 0 fail
shellcheck 0.11 -S warning on every changed script no new warnings against main

Flake diff for review (nothing evaluated):

  • the sidestr upstream bake (lib/sidestr-upstream.nix, /opt/agentbox/sidestr/upstream);
  • the nostr-gateway and tailscale-up comments, and tailscale-up's --authkey=file: branch under the flag;
  • [program:docker-read-proxy] and [program:serve-identity], both of which exit 0 with the flag off;
  • the producer comments;
  • the /run/secrets tmpfs in the baseline mounts;
  • role-accounts isolate, which renders supervisord.roles.conf and role-secrets.tsv, and ships role-accounts.json;
  • the role passwd lines;
  • devuser removed from root:x:0:;
  • the role and ab-identity-port group lines.

The compose changes are the /run/secrets tmpfs (docker-compose.yml:110) and the docker-socket group handling in the override. lib/agentbox-manifest.nix has one change, 3 lines.

🤖 Generated by Claude Code

claude and others added 30 commits October 3, 2026 08:56
…licy.json

Catalogue only; no product behaviour changes. config/egress-policy.json gains
a `.register` key: 31 routes by which a prompt, code, a secret-adjacent string
or a key leaves the box, each with what leaves, destination class, gate,
accepted-egress record and notes, every claim cited path:line at 0919dc3.
The ADR-2026 `paths` contract is untouched (egress-boundary: 25 passed).

scripts/ci/render-egress-register.js renders the table into
docs/SECURITY-profiles.md between markers; --check (wired into invariants.yml
beside check-adr-index) fails on drift, an unmarked gap or a citation outside
its file. Gaps are marked, not filled: 5 routes have no gate, 18 have no
accepted-egress record. Primary-model egress is recorded as accepted per Q14;
provider credentials are listed as devuser-class per Q8.

Linked from docs/LAN-door-threat-model.md (G-10) and ADR-2090 (dated
cross-reference, Decision unchanged).

Co-Authored-By: jjohare <github@thedreamlab.uk>
a10f336 touched governed paths of both records (invariants.yml; the
egress-policy.json `register` key) without touching either decision. Dated
notes record what changed and what was checked; verified_commit bumped.
Status axes unchanged.

Co-Authored-By: jjohare <github@thedreamlab.uk>
b6c44c9 changed run-producer.sh (a governed path) without re-verifying the
record; the invariants job has failed at the ADR index step since. The decision
is unaffected.

Co-Authored-By: jjohare <github@thedreamlab.uk>
… (W0)

Tests first for custody X-1 step 1, workstream W0. All five fail against
origin/main:

- RC-X1-01: a probe planted in ~/workspace/.cargo/bin runs on the pre-W0 root
  PATH (control) and must not after _ab_root_path_sanitise; the sanitiser runs
  before the stage dispatch; the profile snippet appends the cargo bin for
  devuser shells only.
- RC-X1-02: Stage B is one-shot per container start, guarded by a root 0700
  state dir in sticky /tmp (not /run: that tmpfs is owned by uid 1000, so
  devuser can rename anything directly under it).
- RC-X1-03: the baked /etc/group gives root no members (live check gated on a
  rebuilt image).
- RC-X1-04: the docker socket chmod o+rw runs only with role_isolation off;
  on, the host inode is left alone and a widened socket is reported degraded.
- RC-X1-05: allowlist fixtures, a fake daemon and the real docker CLI for the
  GET-only read proxy (ps/inspect/logs pass, exec/run/archive/export refused).

Co-Authored-By: jjohare <github@thedreamlab.uk>
…(G-5)

Adds `mirror_key`, the Rust side of the phone-key contract in
config/hooks/nostr-live-mirror.cjs:200-221: child_sk =
HMAC-SHA256(operator_sk, tag), tag defaulting to "agentbox-mirror-v1".
RustCrypto hmac 0.12 over sha2 (hmac 0.12.1 was already locked via
nostr-bbs-core, so the lock gains one edge and no crate). The JS is
unchanged; the phone key does not move.

tests/mirror_key.rs: 7 known-answer tests on synthetic keys, expected
values agreed by Node crypto, Python hmac and @noble/curves. The aaaa
vector's child pubkey is the existing CHILD_RECIPIENT fixture in
tests/sovereign/egress-boundary.test.js. Scalar 6 (odd y) pins the
hazard: the HMAC must take the env hex as supplied, not the even-y
negated scalar identity.rs persists, or an odd-y house key would hand
the phone a different child.

Cargo.lock: hand-applied single edge. A local cargo resolve also bumps
solid-pod-rs/nostr-bbs path deps to the sibling checkouts' versions;
that drift is pre-existing (--locked fails on main too) and is not
carried, because lib/nostr-pod-bridge.nix fetches the pinned revs.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…law-server (Q15)

Q15 by evidence, 2026-10-03, read from the dreamlab relay (every stored
event of kinds 31400-31403, 21 in total) and VisionClaw source, without
touching any .env:
- VisionClaw signs governance with ACSP_PANEL_NOSTR_PRIVKEY, else
  VISIONCLAW_NOSTR_PRIVKEY (elevation_actor.rs:199-200,
  decision_elevation_actor.rs:183-184, app_state.rs:1362-1363).
- The only VisionClaw-shaped 31402s (acsp/events.rs tag set: priority,
  category, subject-kind) are signed by 11ed6422…663c (2026-08-11).
- b4165401…2f7a signs only 31403 decisions, as the responder; it is
  [sovereign_mesh.operator].pubkey_hex, the operator's NIP-07 key.
So the comments naming b4165401 "visionclaw-server governance
publisher" were wrong, and VisionClaw still shares the house key: a
K_broker is needed. Comment-only change; goldens are frozen fixtures
and stay as captured.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ion, mirror KAT dep)

ADR-2012, 2019, 2020, 2023, 2028, 2029, 2033 and 2105 were tripped by
comment-only changes to agentbox.toml (and setup/agentbox.default.toml
for 2028/2029). ADR-2030 was tripped by nostr-pod-bridge gaining hmac
0.12, which is MIT OR Apache-2.0 and was already in the lock; the crate
licence is unchanged and the licence gate passes. ADR-2012's note
corrects the label at its :157-160 (b4165401 is the operator's 31403
decision signer) and confirms that the key split has not landed, so its
review_trigger has not fired.

Index check leaves only ADR-2103's pre-existing staleness, from
run-producer.sh. That is also present on origin/main and is not this
branch's.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…eld on D1-D3

Co-Authored-By: jjohare <github@thedreamlab.uk>
… (custody W0, bypass 2)

devuser could make root run its code. Both boot stages run as root, and the
entrypoint PREPENDED the devuser-owned ~/workspace/.cargo/bin to PATH (Stage A
for PID 1 and every supervised child, Stage B again for [program:bootstrap]).
With the supervisor socket at 0766, devuser could plant a binary there named
after any tool the boot calls and `supervisorctl start bootstrap`.

- _ab_root_path_sanitise runs once, above the stage dispatch, so it covers
  both stages: only /nix/store/* and the image's fixed system dirs survive;
  relative, dotted, globbed and devuser-owned entries are dropped, with a
  fallback when nothing is left. _ab_cargo_bin_on_path is deleted (rune is
  baked; nothing in the boot used the workspace cargo bin).
- Stage B claims a one-shot sentinel by atomic mkdir in a root 0700 state dir
  that Stage A arms. A replay exits 0 as a no-op; an untrusted dir (wrong
  owner, loose mode, symlink, missing) makes Stage B refuse and exit 1. The
  dir is /tmp/.agentbox-root, not /run: /run, /var/run and /var/log are
  tmpfs mounts owned by uid 1000, so devuser can rename anything directly
  under them, root-owned or not; /tmp is root 1777 and its sticky bit stops
  that. Move it under /run/secrets once W1 makes that a root mount point.
- The runtime-env and fish snippets now APPEND the cargo bin for devuser
  shells only (id -u guard), so a planted or stale workspace binary cannot
  shadow an image tool and a root shell sourcing the file is untouched.

Tests: RC-X1-01 14/14, RC-X1-02 16/16 (both red at 495f29c). The second
commit's regex fix in RC-X1-02 corrects the test, not the contract.

Co-Authored-By: jjohare <github@thedreamlab.uk>
… REBUILD to undo)

The baked /etc/group read `root:x:0:devuser`, so every devuser process ran
with supplementary gid 0 and could read or write anything root left
group-accessible. Custody isolation (design §2.1: "devuser ∉ {root, docker}")
cannot hold while devuser is in group root.

This is the one W0 change that is not flag-reversible: /etc/group is baked
into the read-only image, so applying it and rolling it back both need
`./scripts/launch.sh rebuild dev`. It ships alone so it can be rehearsed and
reverted alone (git revert this commit, rebuild).

R4: a bounded scan of /etc, /opt/agentbox, /var/lib, /run, /var/run,
/var/log, /home/devuser and /tmp in the running container found no file that
devuser reaches only through group 0. The three group-0 sockets
(supervisor, tailscaled, nvidia-ctk hook dir) are all o+rw or o+rwx, so
nothing listed loses access. An unlisted reliance would show as EACCES after
the rebuild.

wheel:x:998:devuser is unchanged: sudo is inert under no-new-privileges.

Tests: RC-X1-03 static 2/2 (red at 495f29c); live `id -G devuser` check
runs with AGENTBOX_RC_LIVE=1 in the rebuilt image.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…n in NIP-07

The container never mints, holds or backs up K_admin; only its public
key comes back for the forum admin list. Adds a key table whose K_admin
row awaits that public key. D1 awaits the owner's age recipient; D3 open.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ustody W0, bypass 1)

devuser could drive the host's Docker daemon, which is root on the host:
the entrypoint ran `chmod o+rw /var/run/docker.sock` on every boot. Any
in-container custody boundary is void while that holds.

- [security].role_isolation (boot-class, default false) is read once in
  Stage A with the existing manifest reader and exported as
  AGENTBOX_ROLE_ISOLATION, so supervisord and every program agree.
- _ab_docker_socket_access: flag off runs the original
  `chmod o+rw "$sock" 2>/dev/null || true` and nothing else (byte-identical
  behaviour). Flag on never chmods the socket in either direction (it is the
  host inode, R2); if devuser can still connect (owner, group or other bits)
  it records degraded:docker-socket in /run/secrets/role-isolation.state and
  logs ROLE-ISOLATION-DEGRADED, fail loud, not fatal. The state write
  removes and re-creates with O_EXCL, so it never follows a planted symlink.
- config/docker-read-proxy.cjs (node:http + node:fs, no new dependency):
  GET/HEAD only on /_ping, /version, /info, /containers/json,
  /containers/<id>/json and /containers/<id>/logs, with an optional /v1.N
  prefix. Everything else gets 403 and never reaches the daemon, including
  the read-shaped exfiltration routes (archive, export, attach). Encoded
  paths, dot segments, absolute-form and Upgrade are refused. It binds
  /run/docker-ro.sock with umask 0111 (no chmod race), then drops to
  uid/gid 65534 with only the socket's group and refuses group 0.
- [program:docker-read-proxy] runs it via `env -i`, so the .env secrets PID 1
  inherits never reach it. It exits 0 at once while the flag is off.
  TODO(custody W1): move it into the role registry.
- Under the flag, Phase 8 exports DOCKER_HOST=unix:///run/docker-ro.sock for
  devuser shells, so `docker ps/logs/inspect` keep working and `docker exec`
  moves to the host shell (Q1 default).
- Compose override comments state the real reach of group_add and R2.
- tests/runtime-contract/README.md: RC-X1 rows and the host-half contract
  for R2, for the W6a rehearsal to pick up.

Tests: RC-X1-04 16/16 plus a live check that is skipped until a rebuilt image
runs with the flag on; RC-X1-05 6/6, including the real docker CLI against
a fake daemon (ps, inspect and logs pass; exec and run fail; no mutating
request reaches the daemon). Both were red at 495f29c.

Co-Authored-By: jjohare <github@thedreamlab.uk>
The acceptance gate for [security].role_isolation (custody design §4), written
before any isolation code so every later workstream has a test to turn green.

- scripts/activation/role-isolation-rehearsal.sh: container half, as devuser.
  (a) role secrets and at-rest copies refused with EACCES, /run/secrets a root
  0711 mount point; (b) role programs RUNNING under their role uid, environ
  unreadable; (c) pods NIP-98 and a dry-run forum event made by the identity
  port verify with NostrBridge.verifyNip98 / nostr-tools, relay NIP-42 AUTH
  accepted, dm_unwrap / foreign URL / generic sign refused, one sign receipt
  per call; (d) producer under its role uid with a block younger than
  2 x interval; (e) classified variable names absent from argv, devuser
  environ, supervisor environment= lines, identity.env and /var/log (names,
  never values); (f) not in group 0, no docker, no sudo, no role gid on the
  socket's group.
- scripts/activation/role-isolation-rehearsal.host.sh: host half, as root on
  the host; refuses inside a container and on a dirty scripts/activation. Root
  probe piped to docker exec with a /nix/store-only PATH. Role-uid reads via
  setpriv, root-view environ names, host getent 960-979 collisions (§8.5),
  host socket mode recorded (Q2).
- docs/estate-closeout/schema/x1-rehearsal.schema.json: receipt schema;
  evidence keys naming a value-bearing field are refused.
- tests/config/role-isolation-rehearsal.test.sh: scratch root, fake /proc and
  supervisor; PASS, flag-off STAGED, one FAIL per check, two
  required-but-skipped FAILs, value-free receipts, host half. 17 cases.

Exit 0 PASS, 2 STAGED (flag off), 1 FAIL; required-but-skipped is FAIL.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…rows would fail

Container half run as devuser on image sha256:ef292f55… with
[security].role_isolation absent (false): exit 2, every row not_applicable,
35 of 42 would fail. Every check (a)-(f) is red, which is the starting line:

(a) /run/secrets devuser-owned 0700 on /run's filesystem; no per-role dirs;
    nostr.key, agentbox-core.json, the producer key and cookie and both
    volume directories open to devuser.
(b) nostr-relay, nostr-gateway, nip98-proxy and sidestr-faucet run as uid
    1000 with readable environ; [program:sidestr-producer] is STOPPED.
(c) no identity port socket; nothing attempted, all FAIL.
(d) the dreamlab tip is fresh, but its producer is not running under
    supervisor (a hand-started process carries the chain).
(e) 942 classified-name hits across devuser environs (AGENTBOX_NSEC,
    AGENTBOX_PRIVKEY_HEX, JUNKIEJARVIS_PRIVKEY_HEX, NIP98_PROXY_*,
    TAILSCALE_AUTHKEY); argv, supervisor text and logs clean.
(f) devuser in group 0; docker version answers; the socket's gid 965 equals
    the §2.2 uid of ab-faucet-dreamlab.

Co-Authored-By: jjohare <github@thedreamlab.uk>
… config

config/custody/g5-key-split.json is the single place the owner's new
PUBLIC keys land (null until supplied; K_admin deliberately absent, D2).
tests/sovereign/g5-key-split.node-test.js reads it and checks the real
path: agentbox.toml -> agentbox-manifest nip98-config -> proxy.mjs.
- G5-1 shape: public keys only, no secret-shaped fields, known verifiers.
- G5-2 each supplied key is a valid BIP-340 x-only point (noble), new,
  and distinct from the house key.
- G5-3 projection keeps 11ed6422 (until withdrawn) and carries each key.
- G5-4 the running proxy admits house key + each key, 401s an outsider.
- G5-5/6 dual-admit and withdrawal mechanics with a synthetic candidate.
The session-cookie path enforces the same allowlist as NIP-98 and needs
no identity private key (its MAC uses a pinned test secret), so a public
key alone proves admission. 6 pass, 2 skip until K_browser is supplied;
mutation-checked against a key missing from the toml (4 fail), a typo
(5 fail), a reused house key (1 fail), early house-key removal (3 fail),
and a correctly added key (8 pass, 0 skip).

Handoff note updated for D2/D3: keys are minted in their holders, so
the owner, who holds the age identity, backs them up and tests the
restore. VisionClaw sets only VISIONCLAW_NOSTR_PRIVKEY (governance via
fallback, NostrBridge, beads), so K_broker replaces that variable; the
mint uses nostr-tools inside visionclaw_container and the .env swap
reads the secret from a file, never argv. browsercontainer's /tmp
profile has no Podkey (Proton Pass is present); the owner confirms
where the copy actually lives.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…(G-5)

Adds `mirror_key`, the Rust side of the phone-key contract in
config/hooks/nostr-live-mirror.cjs:200-221: child_sk =
HMAC-SHA256(operator_sk, tag), tag defaulting to "agentbox-mirror-v1".
RustCrypto hmac 0.12 over sha2 (hmac 0.12.1 was already locked via
nostr-bbs-core, so the lock gains one edge and no crate). The JS is
unchanged; the phone key does not move.

tests/mirror_key.rs: 7 known-answer tests on synthetic keys, expected
values agreed by Node crypto, Python hmac and @noble/curves. The aaaa
vector's child pubkey is the existing CHILD_RECIPIENT fixture in
tests/sovereign/egress-boundary.test.js. Scalar 6 (odd y) pins the
hazard: the HMAC must take the env hex as supplied, not the even-y
negated scalar identity.rs persists, or an odd-y house key would hand
the phone a different child.

Cargo.lock: hand-applied single edge. A local cargo resolve also bumps
solid-pod-rs/nostr-bbs path deps to the sibling checkouts' versions;
that drift is pre-existing (--locked fails on main too) and is not
carried, because lib/nostr-pod-bridge.nix fetches the pinned revs.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…the isolated supervisor config (custody W1)

config/role-accounts.json is the single source for eight role accounts
(uid = gid, 960-979, 965 reserved: it is the host docker gid compose
group_adds, so a role there would sit in the Docker socket's group).
`agentbox-manifest role-accounts` validates it, prints the passwd/group
lines flake.nix bakes, and derives /etc/supervisord.isolated.conf and the
/run/secrets delivery plan from the rendered /etc/supervisord.conf. The
isolated config is a pure function of today's, so the two cannot drift:
only the role programs' user= and environment= lines change.

/run/secrets becomes its own root-owned tmpfs (mode 711) in both modes.
With [security].role_isolation on, the entrypoint keeps it root-owned,
delivers each role's secrets into /run/secrets/<role>/ (0500 dir, 0400
files, owned by the role) from the secrets volume and the classified
PID-1 variables, unsets those variables before exec, moves W0's Stage-B
guard to /run/secrets/.root-guard, and execs the isolated config. With
the flag off every statement is today's (tests/config/role-isolation-boot).

Tests without Nix: role-isolation-supervisor (19), role-secrets-delivery
(26), role-isolation-boot (22), 12 Rust unit tests; W0's RC-X1 suite and
the existing entrypoint suites still pass. Nix was not evaluated here.

Co-Authored-By: jjohare <github@thedreamlab.uk>
The key the entrypoint already reads (W0) now exists: agentbox.toml and
setup/agentbox.default.toml ship it false, the schema types it, and the
catalogue carries module role-isolation. Apply class is boot, not
rebuild: accounts, both supervisor configs and the /run/secrets mount
ship in every image and the entrypoint picks at boot (ADR-039's
definitions); the image that first carries W1 needs one rebuild, which
the summary says. Validator and catalogue check pass.

Co-Authored-By: jjohare <github@thedreamlab.uk>
… (custody W3)

`nostr-pod-bridge serve-identity` holds the sovereign key (and, when its file
is present, JunkieJarvis's) and answers on a unix socket,
/run/agentbox/identity.sock (0660; group from AGENTBOX_IDENTITY_SOCK_GID).
Every connection is authorised by the SO_PEERCRED uid against the checked-in
config/custody/identity-port-acl.json, which maps uid -> named operation ->
keys -> kinds. Socket mode only narrows who can connect.

Closed operation list (ADR-2101 :110-111): pubkey, nip98, sign_event,
forum_event, nip42_auth, mirror_key. Names follow W6a's rehearsal and design
§2.5 (the brief's nip98-header / sign-event / mirror-key are these). Anything
else, including dm_unwrap and a generic `sign`, is refused as
{"refused": {"op", "reason"}}.

- The ACL loader refuses unknown ops, undeclared keys, kinds on kindless ops,
  and kinds 27235, 22242, 31400-31405 and 38414 outright.
- nip98: URL must sit under the key's prefix allowlist (origin-exact,
  segment-bounded); the u tag is cut at ?/# exactly as buildNip98Header does;
  created_at is the port's.
- nip42_auth: relay must be in the key's relay list.
- mirror_key: W8's mirror_key::child_secret (cherry-picked unchanged), tag
  from the server env only, so the port is not an HMAC oracle. The child
  secret is returned only on a `secret: true` grant (devuser: the live-mirror
  hook signs its self-DM with it, nostr-live-mirror.cjs:487).
- One receipt JSONL line per decision under /var/lib/agentbox/events/sign/,
  never content, challenge, header or key material. If an admission's receipt
  cannot be written, the request is refused and the signature discarded.
- Signing is nostr-bbs-core sign_event only; no new primitive. url, base64
  and zeroize were already in the lock graph (three edges, no new crate; lock
  hand-applied like W8's, because the sibling path deps drift on a resolve).
- `sign-request <op>`: stdin JSON -> stdout JSON; exit 1 refused, 2 no port.
- AGENTBOX_ROLE_ISOLATION != 1: serve-identity prints one line and exits 0
  without reading the ACL or any key.

Tests: 21 unit (policy over fake peers, per-caller kinds, malformed requests,
secret absent from responses and receipts, odd-y key), 8 integration against
the real binary (uid outside the ACL refused through real peer credentials,
the rehearsal's (c) contract, socket 0660 + group, nothing secret on argv or
environ, world-writable socket dir refused, flag off binds nothing).
tests/contract.rs now recurses into src/ module directories.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ody W3)

pod-signer gains an identity-port source: `nostr-pod-bridge sign-request
nip98` with {key: core, method, url, payload_sha256}. It is selected by
[security].role_isolation (or AGENTBOX_ROLE_ISOLATION=1), or explicitly by
[integrations.solid_pod_rs].sign_source = "identity-port". When selected it is
the only source: no identity file or stack key is read. The payload hash is
computed exactly as NostrBridge.buildNip98Header computes it.

ADR-2064 is unchanged: a refused port (exit 1), an absent one (exit 2) or any
failure to answer throws, and the adapter turns that into SigningUnavailable
before a byte is sent. sign_requests = false still returns null before any of
this runs, and with the flag off and no sign_source nothing new executes.

tests/sovereign/identity-port.node-test.js spawns the real serve-identity with
a throwaway key and verifies with the real NostrBridge.verifyNip98: CLI header
with bound body; every pods op through resolveAdapters (both impls) signed by
the port with an empty identity root; no socket and foreign URL both fail
closed with zero requests; flag off never consults the port. Skips, and says
so, when the binary is not built. Added to test:node.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…custody W3)

W1's isolated supervisor config has not landed, so the [program:identity-port]
block ships as config/supervisor/identity-port.conf.fragment: Nix-templated
text with @IDENTITY_USER@ and @IDENTITY_PORT_GID@ for W1's role registry, plus
the directory, key-file and group layout Stage A must provide. `env -i` keeps
PID 1's inherited .env out of the process. Nothing reads the file at run time.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…(custody W3)

ADR-2030 was tripped by nostr-pod-bridge gaining url, base64 and zeroize,
all MIT OR Apache-2.0 and already in the lock. The licence gate reports OK
for 9 directories. ADR-2078 was tripped by pod-signer's flag-gated
identity-port source. Same sovereign key, new custody, and ADR-2064's
fail-closed behaviour is preserved. The amendment design §5 asks for is
left to ADR-2122.

The other stale records in the index were tripped by W0's flake and
entrypoint paths, or (ADR-2103) were already stale before this branch.
None of them is this branch's.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…rehearsal (custody W1)

The W6a rehearsal reads /etc/agentbox/role-accounts.json when the image
ships it and otherwise derives a table whose faucet uid lands on 965, the
host docker gid. Shipping the real table makes the rehearsal check the
accounts that were built.

Co-Authored-By: jjohare <github@thedreamlab.uk>
… W3 follow-up)

W3's identity port listens at /run/agentbox/identity.sock, the path the W3
brief fixes (root-owned /run/agentbox with the sticky bit; the socket 0660 to
the callers' group). Design §2.5 had /run/secrets/port/identity.sock, which is
what the rehearsal assumed. Check (c) now reads RH_IDENTITY_SOCK, else
AGENTBOX_IDENTITY_SOCK (the variable the client itself reads), else
/run/agentbox/identity.sock.

The contract header now matches the implementation. A refusal exits 1 with
{"refused": {"op", "reason"}}, no port exits 2 with {"unavailable": ...},
and receipts go to sign-<date>.jsonl. The refusal row prints .refused.reason,
falling back to a bare string. The fixture's fake client returns the same
shape and exit code.

tests/config/role-isolation-rehearsal.test.sh: 17 passed, 0 failed. Driven
against the real serve-identity, all nine (c) rows pass (socket, pubkey,
pods-nip98 via NostrBridge.verifyNip98, forum-event, relay-nip42, three
refusals, 7 receipt lines).

Co-Authored-By: jjohare <github@thedreamlab.uk>
Folds custody/w3-rehearsal-port into W3, carrying W6a's rehearsal (3cd5614,
bbdf41d) with it, since the fix edits that file.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ity port (proposed, partial, inactive)

Records the custody design's sections 2-5 as the decision, with the
rehearsal (W6a) as acceptance and an honest list of what is owed before
the flag may be turned on (W2 at-rest lockdown above all: until it
lands devuser can still read the at-rest copies). Deviations from the
design are named with reasons: uid 965 reserved (host docker gid), role
homes under /run/secrets, no role-exec yet, apply class boot.

Re-verifies the 21 records whose governed paths custody W0 and W1
changed (ADR-2103 was stale from b6c44c9 on main and is re-verified
on its own evidence). ADR-2029 records that W0 retired D4's global
cargo-bin prepend for root; ADR-2020 records the identical-in-effect
re-check for the new gate. ADR-2066 gets a dated note on the devuser
key path under the flag. Index regenerated.

ADR-Ratchet: ADR-2122 documents custody X-1 step 1 (swarm custody-isolation-2026-10-03, W1)
Co-Authored-By: jjohare <github@thedreamlab.uk>
ADR-2103 conflicted: main's re-verification (chain-event announce, at 0919dc3) and W1's
(at 055c06f) both answered b6c44c9. Both dated notes are kept, main's first;
verified_commit takes W1's for now and is re-stamped once at the integration head.

Co-Authored-By: jjohare <github@thedreamlab.uk>
ADR-2013 conflicted only on verified_commit (W1 at 055c06f, W7a at a10f336); both
dated notes merged cleanly and are kept. W7a's stamp is taken for now and re-stamped once
at the integration head.

Co-Authored-By: jjohare <github@thedreamlab.uk>
claude and others added 12 commits October 3, 2026 09:57
…s into ADR-2122

Queen's disposition 4 (§13): W3's identity-port section goes into ADR-2122, which W1 created, as
a dated subsection. W3 wrote no prose section, so "The identity port as built (W3) — 2026-10-03"
is written from the merged code: program, closed operation set, ACL callers, key files, socket,
receipts and the pods consumer. "Integration resolutions — 2026-10-03" records the config name,
the single role table, the socket group from the table, and the deviation from a root-owned
/run/agentbox (devuser owns /run), with the reason.

§4 now points at the built set instead of the design's longer operation list. The deviation
bullet that my rename had made self-contradictory ("roles, not roles") is rewritten as resolved.
Status moves the pods cutover to built and leaves W3b owed. The consequence count goes to 9 group
lines. verified_paths gains the port, ACL, pods signer and rehearsal; verified_commit is
re-stamped once at the integration head. W2's env-scrub section and the W9 pointer are folded in
when those branches land.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…tody integration)

The subcommand's help said role groups have no members, ever; that still holds, but the
command now also prints the table's shared groups (ab-identity-port) with their listed members.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…dy integration)

shellcheck 0.11.0 -S warning on every shell script the integration changes: six warnings, all
in W1's tests. SC1090 on the deliberate sources of config/lib/role-custody.sh gets a source=
directive; the stub's AB_RC_FAILURES is read by the eval'd entrypoint block (SC2034 disabled
with that reason); an unused read target becomes _. config/entrypoint-unified.sh carries the
same 20 warnings as main, none new. Suites unchanged: boot 22/22, supervisor 23/23.

Co-Authored-By: jjohare <github@thedreamlab.uk>
The queen's order puts W5 after W1 and before W3; it landed after W3, W7a, W8 and the
integration resolutions were already on this branch. The merge was clean (W5 is based on W1 and
touches lib/sidestr-upstream.nix, the runner and ADR-2103 only), so the tree is the same as with
the intended order; rebuilding the branch would only have re-done every resolution.
ADR-2103 now carries three dated 2026-10-03 notes (main, W1, W5); verified_commit is re-stamped
once at the integration head.

Co-Authored-By: jjohare <github@thedreamlab.uk>
Conflicts, all resolved additively:
- docs/SECURITY-profiles.md: W7a's prompt egress register and W2's environment classes are two
  new sections; both kept, register first.
- management-api test:node: the union of the suite lists (W2 adds role-secret.node-test.js).
- tests/config/role-isolation-boot.test.sh: W2's scrub stub and the deliver -> pick -> scrub
  order are taken, with W2's supervisord.isolated.conf renamed to supervisord.roles.conf
  (integration resolution 1).
- ADR-2122: W2's §3a, its status updates and its dated note are kept beside W3's and the
  integration sections; verified_paths is the union of both sides (W2's side had dropped the
  identity-port and rehearsal paths).
- ADR-2012, 2013, 2026, 2028, 2029, 2033, 2078: every dated note kept; verified_commit is
  re-stamped once at the integration head.

The queen's loader rule ("file wins; the bare variable only flag-off; under the flag a bare
variable is logged ROLE-ISOLATION-LEAK and ignored") is W2's as merged:
management-api/lib/role-secret.js resolveRoleSecret and nostr-pod-bridge::role_secret read
<NAME>_FILE, then $AGENTBOX_SECRETS_DIR/<NAME>, then the bare variable only with the flag off.
nip98-proxy loads that resolver and fails closed without it; the gateway and nostr-send go
through config/hooks/lib/operator-key.cjs. W3 does not touch these loaders, so nothing
collided there.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…lassifies by W2's ROLE class (custody integration)

W2's env-secret inventory cross-checks every variable name the code reads. After the merge it
failed on six names W3 and the integration introduced, none of them secret: the port's ACL path,
key dir, receipt dir, socket path and socket gid, and pod-signer's AGENTBOX_POD_BRIDGE_BIN.
They are NON_SECRET, listed exactly (KEY_DIR is secret-shaped, so no pattern may cover it).
Inventory PASS: 648 names, ROLE 11, DEVUSER_CLASS 35, NON_SECRET 602; its node test 13/13.

The rehearsal's check (e) classified names from W1's from_env alone and, in my earlier commit,
dropped AGENTBOX_AGENT_PRIVKEY_HEX on the grounds that management-api reads it. W2 settles that
the other way: config/custody/env-classes.json puts it, OPERATOR_NOSTR_PRIVKEY and
AGENT_PRIVKEY_HEX in ROLE ab-identity (w2_only, delivered by W2's capture) and TAILSCALE_AUTHKEY in
ROLE root. normalise_registry now takes each role's names as W1's from_env plus W2's ROLE entries
for that role, and the root list from the ROLE class instead of a hard-coded constant. A missing
ROLE class exits 1. The --print-registry case pins both. Rehearsal 21/21.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…r plant one beside it (custody integration)

Asked for by the queen when accepting the correction to §13: the property the disposition got
wrong is that a directory is only as safe as its parent. Check (a) now probes each socket
directory the role table declares (groups -> <secrets_root>/<group>). It records the
directory's owner, group and mode (0750, the owner role, the group's gid), then as devuser
attempts `mv -T` of the directory and a `mkdir` beside it. Both must be refused. A directory
that does move is moved straight back and the row fails. Nothing is read, and nothing survives
the probe.

Fixture: the scratch /run/secrets is 0555, standing for the root 0711 mount the runner cannot
write. Case 17 leaves it writable, the /run/agentbox situation, and (a) fails with "rename
succeeded; create beside planted", the socket intact and no probe entry left. The PASS fixture
records "rename refused; create beside refused". Rehearsal 23/23. shellcheck clean.

ADR-2122 gains two dated notes. The first is the correction itself: /run is uid=1000 tmpfs
(docker-compose.yml:109, flake.nix:3273), while /run/secrets is its own uid=0 mode=711 tmpfs
(:110, :3278). It also notes that a root-owned /run/agentbox would have broken bootstrap-seal's
/ready signal. The second is the W9 pointer: K_browser lives in the browser sidecar's Podkey and
is recorded in browsercontainer/README.md; no role account here holds it.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…integration)

`mkdir -p -m 0700` applies the mode only to the deepest directory, which was the intent, but
shellcheck flags it as the only warning the integration adds to config/entrypoint-unified.sh.
The capture now creates the secrets root with mkdir -p (as before) and the role directory with
mkdir -m 0700, and the chown 0:0 and chmod 0700 that follow are unchanged. Behaviour is the
same. RC-X1-06 30/30, role-isolation-boot 22/22, check-secret-not-in-env PASS.

Co-Authored-By: jjohare <github@thedreamlab.uk>
)

Queen's disposition 5 (§13): keep every dated note and give each record one verified_commit, at
the integration head. `adr-index-gen --check` named 25 records stale after all eight merges and
the resolutions. Each gets one dated note listing exactly the commits that touched its governed
paths since its previous stamp (from `git log <stamp>..3b54129 -- <path>`), says why its
decision is untouched, and is stamped 3b54129. ADR-2122's note also records the suite totals
at that head; it stays proposed/inactive until the owner's rebuild passes the rehearsal.

Earlier same-day notes from main, W1, W2, W3, W5, W7a and W8 are all kept. No record carries two
verified_commit lines. adr-index-gen --check and --check-index: 114 valid, README in sync;
adr-ratchet OK.

Co-Authored-By: jjohare <github@thedreamlab.uk>
The PR's CI failed three jobs; each fix is behaviour-neutral:
- role_accounts.rs: DirSpec.purpose was never read (clippy dead_code). The
  'check' summary now prints one line per created directory with its
  purpose, as it already did for roles and shared groups.
- role_accounts.rs: inserts.sort_by(b.cmp(a)) -> sort_by_key(Reverse). Both
  sorts are stable, so the rendered roles config is byte-identical.
- role_accounts.rs test: useless format! on a literal.
- flake.nix: 'parent = c.parent;' -> 'inherit (c) parent;' (statix W04).
  This line came from main (f746541) after main's last flake-check run;
  flake-check runs only on PRs touching the flake, so this PR is the first
  to trip it.

Co-Authored-By: jjohare <github@thedreamlab.uk>
32cedf9 touched flake.nix (one statix inherit) and role_accounts.rs
(clippy only). Each record gets a dated note; every prior note is kept and
each record carries one verified_commit.

Co-Authored-By: jjohare <github@thedreamlab.uk>
claude and others added 14 commits October 3, 2026 10:28
…how the owner turns it on

The docs pass for swarm custody-isolation-2026-10-03. Everything is described
as staged: nothing here is live until the owner's rebuild and a passing pair of
rehearsal receipts.

- SECURITY-profiles.md: a role-isolation section with the role table, the
  /run/secrets mount and delivery, the identity port, the environment classes
  under the flag, the flag (default off, boot-class), and what is still open
  (at-rest custody, W3b, W4, the AoE share, Q4, the repo .env). Cited at d03defb.
- docs/developer/role-isolation-runbook.md (new): rebuild, post-rebuild checks,
  the flag-off baseline, the host socket prerequisite, flag on, both rehearsal
  halves, receipt paths, rollback, and the sidecar's Podkey mint and unlock
  steps. It states that the flag-on rehearsal is expected to fail at this head.
- Container reference: the workspace layer (docker exec is no longer a
  monitoring path from inside under the flag), AGENTS.md runtime gotchas
  (roles config, serve-identity and docker-read-proxy EXITED while off,
  podkey-loader), and BASELINE-container 0.5.3 (new programs, the flag
  qualifier, the baked producer, the sidecar loader).
- AGENTS.md rules: one checkout per worker; the two supervisor configs differ
  only in user= and environment=.
- configuration.md: the flag entry now lists the 11 ROLE variables,
  serve-identity and the Docker change, and links the runbook.
- README.md: the producer runs the baked pinned upstream, not a checkout.
- sovereign-stack.md: the identity check printed AGENTBOX_NSEC and
  AGENTBOX_BRIDGE_SK with the flag off; it now greps public names only.

No ADR governs the edited files, so no record is re-verified.
adr-index-gen --check: 114 valid; adr-ratchet: OK.

Co-Authored-By: jjohare <github@thedreamlab.uk>
The workspace instruction layer gained a role_isolation paragraph; tier
projection is unchanged. instructions-layers 9/9; adr-index-gen --check clean.

Co-Authored-By: jjohare <github@thedreamlab.uk>
docker-compose.override.yml listed AGENTBOX_NSEC, AGENTBOX_PRIVKEY_HEX and
TAILSCALE_AUTHKEY under environment:, and docker-compose.hp.yml the first
two. Both files already load .env by env_file, which carries the same
values; the environment: entries were a second, explicit route into PID 1
beside the one the role_isolation boot scrub handles. Removed, with a
comment saying where the values come from.

TAILSCALE_AUTHKEY was not in the brief but is ROLE (role root) in
config/custody/env-classes.json, and the gate flags it, so it goes too.

New gate tests/security/compose-role-env.test.mjs: every docker-compose*.yml,
list/map/flow environment: forms, names checked against the ROLE class.
Red 14/2 -> green 16/0.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…he plan

The brief (diagrams pass) said (d) read .chain/.port/.enabled from
role-accounts.json roles. That held at 3cd5614, which the committed
receipt x1-rehearsal-20261003T090708Z was taken against; 860f58f already
derived chain/port/interval in normalise_registry. What was still wrong:
`enabled` meant only "the plan delivers this role something", and the
manifest's [sidechain].enabled / [sidechain.<name>].enabled was never read.
The live /etc/agentbox.toml is a bind of the repo file, so it can drift
from the image whose plan was resolved at build time, and (d) would probe
(or skip) the wrong chains without saying so. And with no chain on, (d)
failed "no enabled producer" instead of having nothing to check.

Now each producer role carries enabled (manifest; [sidechain].enabled
dominates, a missing key is off) and baked (plan rows). (d) probes when
both are true, fails naming the rebuild when they disagree, and records a
pass when no chain is enabled and none baked.

tests/config/role-isolation-rehearsal.test.sh, new cases 18-21, case 18
against the shipped agentbox.toml [sidechain] tables (dreamlab on :3450,
dreamlab-txbt4 off at 3451 and not probed). Red 23/5 -> green 28/0.
shellcheck not available in this container; CI's ShellCheck job covers it.

Co-Authored-By: jjohare <github@thedreamlab.uk>
Nothing in CI ran RC-X1-01..05, tests/sovereign/role-secret.node-test.js
(path-filtered in contract-tests but no step), the nostr-pod-bridge crate
(identity port, role isolation) or the rehearsal test.
invariants.yml:84 claimed "the loader and consumers run in contract-tests";
they did not.

invariants.yml: RC-X1-01..05 beside RC-X1-06 (scratch-root, no Docker, no
root), compose-role-env (W10 fix 1); paths add tests/runtime-contract/RC-X1-*,
config/docker-read-proxy.cjs, config/custody/**. The :84 label now names a
step that exists.

contract-tests.yml: step "Role-secret loader and consumers (custody W2)"
in the contract job; new job `custody`. nostr-pod-bridge reaches
nostr-bbs-core and solid-pod-rs by ../../../../ path deps, so the job
rebuilds lib/nostr-pod-bridge.nix's layout (agentbox at project/agentbox,
the public siblings beside it) at the revisions read from that file, then
cargo test --locked (whole crate), the node identity-port test against the
built binary (fails on any skip), and the rehearsal test. Paths cover the
crate, its nix pins, config/custody, the role table, agentbox.toml, the
rehearsal scripts, test and receipt schema.

Verified locally in the same layout at the pinned revs: crate 209/0
(identity_port 8, lib 99), identity-port.node-test 10/0/0 skipped,
role-secret 23/0, rehearsal 28/0 with NODE_PATH and no mcp/node_modules,
RC-X1-01 14, -02 16, -03 3, -04 17, -05 6, -06 30, all 0 failed.

Co-Authored-By: jjohare <github@thedreamlab.uk>
… isolation

flake.nix baked wheel:x:998:devuser and sudoers.d/devuser "NOPASSWD: ALL"
into every image. no-new-privileges:true neuters setuid today, but that is
one compose line from undone, and then sudo defeats every role boundary.

/etc is baked into a read-only rootfs and the flag is boot-class, so the
gate cannot live in the flake alone:

- bake: config/bake-devuser-privilege.sh <etc> <0|1>, called by flake.nix
  with roleIsolationBaked = securityCfg.role_isolation or false. Off: group,
  sudoers and the drop-in byte-identical to before (the heredoc keeps
  wheel:x:998:devuser). On: devuser leaves the wheel and root member lists
  (others kept), sudoers keeps root + includedir, no drop-in.
- boot: _ab_devuser_privilege_check in the entrypoint's W0 phase, after the
  docker-socket check. Flag on in an image baked off (a flip without the
  rebuild) records degraded:devuser-sudo in /run/secrets/role-isolation.state
  and logs ROLE-ISOLATION-DEGRADED devuser-sudo naming the rebuild; fail
  loud, not fatal, like docker-socket. Flag off: no-op. Conservative: any
  NOPASSWD grant to devuser, %devuser, %wheel or ALL counts.

tests/runtime-contract/RC-X1-07.sh, fake root seeded from the real heredoc,
the check's state file and stderr as recorder. Red 0/4 (bake absent) ->
green 21/0 (live case skips without AGENTBOX_RC_LIVE). Wired into
invariants.yml. RC-X1-01..06, role-isolation-boot 22/0 and the
check-* gates still pass. Nix not evaluated here (no nix in this container).

Co-Authored-By: jjohare <github@thedreamlab.uk>
…c91e09)

ADR-2122: dated section "Gap fixes after the diagrams pass (W10)", one
entry per fix with its sha; a new Invariants row (devuser not in wheel, no
sudoers grant); the activation consequence (dropping devuser's sudo needs
a rebuild with the flag, the boot reports degraded:devuser-sudo
otherwise); verified_paths add the bake, RC-X1-07, the compose gate and
the two compose files.

ADR-2002, 2009, 2012, 2013, 2028, 2029, 2031, 2033, 2082, 2091, 2092,
2093, 2118, 2119, 2120, 2121: tripped only by W10's edits to flake.nix,
the entrypoint, invariants.yml or the compose overlays; each note says
which edit, and that the record's own decision is untouched.

adr-index-gen --check and --check-index: 114 valid, README in sync.
adr-ratchet: OK.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…e move (custody W2b, W4)

Design 3.2-3.4 was never built: with role_isolation on, the at-rest copies on
the volumes stayed devuser's and check (a) of the rehearsal failed.

- Registry, derived not hand-listed: `role-accounts isolate` now emits
  `atrest <role> <path> <legacy|->` for every resolved file secret and each
  role's `at_rest` (agentbox-core.json, zone-keys.json), and
  `atrestdir <path> <mode>` for `at_rest_dirs` (secrets/ 700, identities/ 711:
  the pay402/Hitch spend keys there stay devuser-class under Q6). A secret's
  `legacy` path (the workspace treasury keys) is read from the canonical
  volume copy under the flag when the program's environment still names it.
- config/lib/role-custody.sh: ab_custody_migrate (flag on) copies an absent
  canonical copy from its legacy twin once (umask 077, fsync, size and
  in-memory byte compare, rename; legacy never touched), refuses a symlinked,
  hard-linked, empty, oversized or first-sighting-differs copy (not re-owned,
  not delivered), hands each copy to its role 0400 and the dirs to root,
  records pre-flag modes once in a root-only record and paths only in
  /run/secrets/role-isolation.migrated. ab_custody_revert (flag off) hands
  them back from a trusted record; never migrated means no change. Both
  idempotent: unchanged owners and modes are not re-set (ctime stable).
- Entrypoint: the custody step runs after the /run/secrets prep and the
  volume-root chown, before `nostr-pod-bridge bootstrap`; the volume loop
  leaves secrets/ alone under the flag; the identity file is ab-identity 0400
  after the bootstrap under the flag.
- W4: producer, mirror and faucet state move to the agentbox-events volume
  under the flag (`dirs` with `seed` rows, copied once, source kept; producer
  dirs group devuser 2750, umask 027, so the mirror reads through the group).
  The role's HOME is tmpfs, so the workspace default restarted the chain and
  emptied the faucet ledger every boot. Flag off with the custody copy ahead,
  producer and faucet refuse rather than fork or re-grant.
- The rehearsal takes its at-rest set from the registry rows; its fixture plan
  follows the generator; case 18 runs the real migrate and (a) passes, 18b
  shows (a) failing without it.
- agentbox.toml: the role_isolation comment no longer says the identity port
  is not built.

Tests: role-custody-migrate 31/31 (new), rehearsal 28 -> 30 (cases 22, 22b), delivery 30,
boot 22, supervisor 23, manifest cargo 121 (4 new + 1 seed), RC-X1-01..06
green, env-secret-inventory PASS.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ove (f93586b)

- ADR-2122: a dated section for W2b/W4 (derived registry, migrate/revert,
  refusals, the sidechain state move and its flag-off guard), the Status and
  Deviations bullets it closes (identities/ 0711 for Q6's spend keys; mirror
  reads through group devuser, no ab-sidestr-read), the remaining owed items
  named, and the Acceptance sentence corrected to the receipt paths the
  scripts write (docs/estate-closeout/x1-rehearsal[-host]-<UTC>.json).
- 17 records whose governed paths f93586b touched (agentbox.toml comment,
  the entrypoint's three custody blocks, run-producer.sh): one dated note
  each, verified_commit bumped.
- docs/estate-closeout/README.md: x1-rehearsal-20261003T090708Z.json is
  superseded (faucet at uid 965); the receipt itself is not edited.

adr-index-gen --check and --check-index: 114 valid, index in sync;
adr-ratchet OK.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ain state move)

Co-Authored-By: jjohare <github@thedreamlab.uk>
Seven SC2034 warnings arrived with W2b-r and W10; none changes behaviour.
- role-custody.sh: two genuinely unused 'type' read targets become '_'
  (one was masked until the other went); AB_RC_REVERTED is a documented
  output, so it keeps a disable with the reason.
- role-custody-migrate.test.sh, role-isolation-rehearsal.test.sh: the
  variables are seams read by the sourced library or by the eval'd
  entrypoint block, which shellcheck cannot see across files; disables
  carry that reason.
Suites: migrate 31, rehearsal 30, delivery 30, boot 22, supervisor 23.

Co-Authored-By: jjohare <github@thedreamlab.uk>
Co-Authored-By: jjohare <github@thedreamlab.uk>
… runner uid 1001, a VM)

The new custody CI job (W10) ran two suites for the first time outside the agentbox container,
and both had assumed it:

- role-custody-migrate "revert exactness": the fixture meant a devuser-owned flag-off volume but
  only seeded identities/ (0:0) in the chown ledger, so every other path fell through to its real
  owner — 1000 in the container, 1001 on the runner. The ledger now records 1000:1000 for the
  whole fixture before identities/ is set to root, so the pre-flag snapshot is devuser's on any
  host. ab_custody_revert is unchanged.
- rehearsal host half "refuses to run inside the container": the test dropped its seam and
  relied on real container detection, which a GitHub runner (a VM) does not trip, so the root
  check fired instead. New seam HR_ASSUME_CONTAINER=1 forces the in-container refusal and cannot
  suppress it; the test uses it everywhere and, when it really is in a container, also asserts
  the genuine detection path without the seam (31 cases here, 30 on a runner).

Co-Authored-By: jjohare <github@thedreamlab.uk>
@jjohare
jjohare merged commit 0110b54 into main Oct 3, 2026
15 checks passed
@jjohare
jjohare deleted the custody/integration branch October 7, 2026 07:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants