diff --git a/.github/workflows/browsercontainer.yml b/.github/workflows/browsercontainer.yml new file mode 100644 index 000000000..7bec82a25 --- /dev/null +++ b/.github/workflows/browsercontainer.yml @@ -0,0 +1,64 @@ +# browsercontainer.yml +# +# W9 (custody-isolation 2026-10-03): the browser sidecar ships the pinned Podkey +# CI artefact and keeps its Chrome profile on a named volume. These tests guard +# the pin (sha256, upstream repo, content), the launch flags per browser, the +# load-once rule and the profile volume. The image itself is built on the host +# (`agentbox.sh browsercontainer rebuild`), never here. + +name: Browsercontainer + +on: + pull_request: + paths: + - 'browsercontainer/**' + - 'docker-compose.browsercontainer.yml' + - 'tests/browsercontainer/**' + - '.github/workflows/browsercontainer.yml' + push: + branches: + - main + paths: + - 'browsercontainer/**' + - 'docker-compose.browsercontainer.yml' + - 'tests/browsercontainer/**' + - '.github/workflows/browsercontainer.yml' + +concurrency: + group: browsercontainer-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + podkey: + name: Podkey pin, launch args, profile volume + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + + # Best effort: populates browsercontainer/vendor/ so the real-artefact cases + # run. If the token cannot read the upstream artefact (or it has expired), + # those cases SKIP and the fixture cases still gate. + - name: Fetch pinned Podkey artefact (best effort) + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: bash browsercontainer/scripts/fetch-podkey.sh fetch + + - name: fetch-podkey.sh (pin, sha256, content, fetch paths) + run: bash tests/browsercontainer/fetch-podkey.test.sh + + - name: launch-chromium.sh flags + run: bash tests/browsercontainer/launch-args.test.sh + + - name: podkey-ctl.js (id derivation, load-once, output hygiene) + run: node --test tests/browsercontainer/podkey-ctl.test.cjs + + - name: compose profile volume + run: node tests/browsercontainer/compose-profile-volume.test.cjs diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml index ada4b2406..7f69a0e19 100644 --- a/.github/workflows/contract-tests.yml +++ b/.github/workflows/contract-tests.yml @@ -17,6 +17,9 @@ on: - 'schema/federation-kinds.json' - 'tests/sovereign/chain-info.node-test.js' - 'tests/sovereign/pod-sovereign-signer.node-test.js' + - 'tests/sovereign/role-secret.node-test.js' + - 'management-api/lib/role-secret.js' + - 'config/hooks/lib/operator-key.cjs' - 'management-api/lib/pod-signer.js' - 'management-api/lib/agent-identity.js' - 'mcp/servers/nostr-bridge.js' @@ -36,6 +39,18 @@ on: - 'management-api/routes/system.js' - 'scripts/activation/sidechain-witness.cjs' - 'scripts/activation/sidechain-demo-witness.sh' + # Custody X-1 (ADR-2122): the identity port (nostr-pod-bridge) and the + # activation rehearsal, job `custody` below. + - 'services/nostr-pod-bridge/**' + - 'lib/nostr-pod-bridge.nix' + - 'tests/sovereign/identity-port.node-test.js' + - 'config/custody/**' + - 'config/role-accounts.json' + - 'agentbox.toml' + - 'scripts/activation/role-isolation-rehearsal.sh' + - 'scripts/activation/role-isolation-rehearsal.host.sh' + - 'tests/config/role-isolation-rehearsal.test.sh' + - 'docs/estate-closeout/schema/x1-rehearsal.schema.json' - 'agentbox/schemas/adapter-contracts/**' - '.github/workflows/contract-tests.yml' push: @@ -54,6 +69,9 @@ on: - 'schema/federation-kinds.json' - 'tests/sovereign/chain-info.node-test.js' - 'tests/sovereign/pod-sovereign-signer.node-test.js' + - 'tests/sovereign/role-secret.node-test.js' + - 'management-api/lib/role-secret.js' + - 'config/hooks/lib/operator-key.cjs' - 'management-api/lib/pod-signer.js' - 'management-api/lib/agent-identity.js' - 'mcp/servers/nostr-bridge.js' @@ -73,6 +91,18 @@ on: - 'management-api/routes/system.js' - 'scripts/activation/sidechain-witness.cjs' - 'scripts/activation/sidechain-demo-witness.sh' + # Custody X-1 (ADR-2122): the identity port (nostr-pod-bridge) and the + # activation rehearsal, job `custody` below. + - 'services/nostr-pod-bridge/**' + - 'lib/nostr-pod-bridge.nix' + - 'tests/sovereign/identity-port.node-test.js' + - 'config/custody/**' + - 'config/role-accounts.json' + - 'agentbox.toml' + - 'scripts/activation/role-isolation-rehearsal.sh' + - 'scripts/activation/role-isolation-rehearsal.host.sh' + - 'tests/config/role-isolation-rehearsal.test.sh' + - 'docs/estate-closeout/schema/x1-rehearsal.schema.json' - '.github/workflows/contract-tests.yml' concurrency: @@ -126,6 +156,12 @@ jobs: working-directory: management-api run: NODE_PATH="$PWD/node_modules" node --test ../tests/sovereign/pod-sovereign-signer.node-test.js + # Custody X-1 W2: the _FILE role-secret loader (management-api/lib/role-secret.js) + # and its consumers (operator-key, pod-signer, agent-identity). invariants.yml points here. + - name: Role-secret loader and consumers (custody W2) + working-directory: management-api + run: NODE_PATH="$PWD/node_modules" node --test ../tests/sovereign/role-secret.node-test.js + # ADR-2097 (amended 2026-10-02): the sidestr rail — k_spend/k_id binding # known-answer test and the /v1/chain/pay pipeline, also under node:test. - name: sidestr rail (k_spend binding KAT, /v1/chain/pay, /v1/chain/payments) @@ -149,3 +185,90 @@ jobs: name: contract-test-results path: management-api/coverage/ retention-days: 14 + + # Custody X-1 step 1 (ADR-2122): the identity port and the activation rehearsal. + # nostr-pod-bridge resolves nostr-bbs-core and solid-pod-rs through + # ../../../../ path dependencies, so the job rebuilds the layout + # lib/nostr-pod-bridge.nix assembles: agentbox at project/agentbox, the two + # public siblings beside project/, at the revisions that file pins (read from + # it, so the pins move in one place). + custody: + name: Custody — identity port and activation rehearsal + runs-on: ubuntu-latest + defaults: + run: + working-directory: project/agentbox + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + path: project/agentbox + + - name: Read the sibling pins from lib/nostr-pod-bridge.nix + id: pins + run: | + forum="$(sed -n 's/^ *forumRev *= *"\([0-9a-f]\{40\}\)";.*/\1/p' lib/nostr-pod-bridge.nix)" + solid="$(sed -n 's/^ *solidRev *= *"\([0-9a-f]\{40\}\)";.*/\1/p' lib/nostr-pod-bridge.nix)" + [ -n "$forum" ] && [ -n "$solid" ] || { echo "::error::forumRev/solidRev not found in lib/nostr-pod-bridge.nix"; exit 1; } + echo "forum=$forum" >>"$GITHUB_OUTPUT" + echo "solid=$solid" >>"$GITHUB_OUTPUT" + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: DreamLab-AI/nostr-rust-forum + ref: ${{ steps.pins.outputs.forum }} + path: nostr-rust-forum + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: DreamLab-AI/solid-pod-rs + ref: ${{ steps.pins.outputs.solid }} + path: solid-pod-rs + + - name: Set up Node 22 (matches the runtime image — nodejs_22 in flake.nix) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: project/agentbox/management-api/package-lock.json + + - name: Install dependencies + working-directory: project/agentbox/management-api + run: npm ci + + - name: Set up Rust + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 + with: + toolchain: stable + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + with: + workspaces: project/agentbox/services/nostr-pod-bridge + + # The whole crate: tests/identity_port.rs (port transport, SO_PEERCRED ACL, + # receipts), the lib's identity_port unit tests, tests/role_isolation.rs + # and the bootstrap/identity suites the port's keys come from. + - name: nostr-pod-bridge tests (identity port, role isolation, bootstrap) + working-directory: project/agentbox/services/nostr-pod-bridge + run: cargo test --locked + + - name: Build the nostr-pod-bridge binary for the node-side port test + working-directory: project/agentbox/services/nostr-pod-bridge + run: cargo build --locked --bin nostr-pod-bridge + + # Without NOSTR_POD_BRIDGE_BIN every case skips; here the binary is built, + # so a skip would mean the job is not testing the port. + - name: Identity port end to end (sign-request client against serve-identity) + working-directory: project/agentbox/management-api + run: | + NOSTR_POD_BRIDGE_BIN="$GITHUB_WORKSPACE/project/agentbox/services/nostr-pod-bridge/target/debug/nostr-pod-bridge" \ + NODE_PATH="$PWD/node_modules" node --test ../tests/sovereign/identity-port.node-test.js | tee "$RUNNER_TEMP/port.tap" + ! grep -Eq '^# skipped [1-9]' "$RUNNER_TEMP/port.tap" + + # The rehearsal's checks (a)-(f) against a scratch root with fake + # /proc, supervisorctl, stat, docker, sudo and curl; (c) signs with + # throwaway keys and verifies with NostrBridge.verifyNip98 (nostr-tools + # from management-api, as the pods signer step does). + - name: Role-isolation rehearsal decides correctly (custody W6a/W10) + run: NODE_PATH="$PWD/management-api/node_modules" bash tests/config/role-isolation-rehearsal.test.sh diff --git a/.github/workflows/invariants.yml b/.github/workflows/invariants.yml index 762be31e0..29c5cb852 100644 --- a/.github/workflows/invariants.yml +++ b/.github/workflows/invariants.yml @@ -9,6 +9,9 @@ on: - 'docker-compose*.yml' - 'docs/adr/**' - 'docs/PROTOCOL-registry.md' + - 'docs/SECURITY-profiles.md' + - 'config/egress-policy.json' + - 'docs/LAN-door-threat-model.md' - 'config/seccomp-agentbox.json' - 'config/entrypoint-unified.sh' - 'management-api/**' @@ -21,6 +24,12 @@ on: - 'tests/config/protocol-registry-lint.test.mjs' - 'tests/config/declared-vs-running.test.js' - 'tests/config/fixtures/runtime-state.json' + # Custody X-1 W0/W2 runtime contracts (RC-X1-01..06) and what they read + # beyond config/entrypoint-unified.sh and flake.nix (both listed here). + - 'tests/runtime-contract/RC-X1-*' + - 'config/docker-read-proxy.cjs' + - 'config/bake-devuser-privilege.sh' + - 'config/custody/**' - 'flake.nix' - 'skills/**' - 'mcp/**' @@ -72,6 +81,39 @@ jobs: - name: check-secret-not-in-env (AGENTBOX_BRIDGE_SK scrubbed before exec) run: sh scripts/ci/check-secret-not-in-env.sh + # Custody X-1 step 1, W2 (bypass 3): every env var name the boot path and + # the role-secret consumers read is classified ROLE / DEVUSER_CLASS / + # NON_SECRET (config/custody/env-classes.json); ROLE vars leave PID 1's + # environment under [security].role_isolation and are read by file. + - name: env-secret-inventory (custody W2 — every env name classified; names only) + run: node scripts/ci/env-secret-inventory.js --check + # The _FILE loader and its consumers run in contract-tests.yml, step + # "Role-secret loader and consumers (custody W2)" (it needs management-api's + # node_modules). Until 2026-10-03 this label claimed that step and none existed. + - name: env-secret-inventory unit tests (custody W2) + run: node --test tests/security/env-secret-inventory.node-test.js + - name: compose-role-env (custody W10 — no ROLE-class variable under a compose environment key) + run: node --test tests/security/compose-role-env.test.mjs + + # Custody X-1 step 1 runtime contracts. Each extracts the real functions from + # config/entrypoint-unified.sh (or reads flake.nix / config/docker-read-proxy.cjs) + # and runs them against a scratch root: no Docker, no root. RC-X1-03's live half + # (AGENTBOX_RC_LIVE=1) needs a rebuilt image and stays off here. + - name: RC-X1-01 root boot runs no binary from a devuser-writable dir (custody W0) + run: bash tests/runtime-contract/RC-X1-01.sh + - name: RC-X1-02 Stage B is one-shot per container start (custody W0) + run: bash tests/runtime-contract/RC-X1-02.sh + - name: RC-X1-03 devuser is not in group root (custody W0; static half) + run: bash tests/runtime-contract/RC-X1-03.sh + - name: RC-X1-04 docker socket widened only with role isolation off (custody W0) + run: bash tests/runtime-contract/RC-X1-04.sh + - name: RC-X1-05 read-only docker proxy for devuser (custody W0) + run: node --test tests/runtime-contract/RC-X1-05.node-test.cjs + - name: RC-X1-06 role-secret scrub contract (custody W2; no Docker needed) + run: bash tests/runtime-contract/RC-X1-06.sh + - name: RC-X1-07 devuser has no sudo route under role isolation (custody W10; bake + boot check) + run: bash tests/runtime-contract/RC-X1-07.sh + - name: check-single-metrics (exactly one collectDefaultMetrics call) run: node scripts/ci/check-single-metrics.js @@ -136,6 +178,13 @@ jobs: - name: check-adr-index (ADR-2001 — docs/adr/README.md matches the records on disk) run: node scripts/adr-index-gen.js docs/adr --check-index + # N-7: the prompt egress register is rendered from config/egress-policy.json + # `.register` into docs/SECURITY-profiles.md. --check fails on a hand edit to + # the table, an unrendered JSON change, an unmarked gap (gate or accepted + # record "none" without its marker) or a path:line citation outside its file. + - name: check-egress-register (N-7 — SECURITY-profiles.md table matches config/egress-policy.json) + run: node scripts/ci/render-egress-register.js --check + - name: adr-ratchet self-test (an ADR added already decided is not backlog) run: bash tests/config/adr-ratchet.test.sh diff --git a/.github/workflows/manifest-validate.yml b/.github/workflows/manifest-validate.yml index 52f7ebe96..378b2d9f1 100644 --- a/.github/workflows/manifest-validate.yml +++ b/.github/workflows/manifest-validate.yml @@ -23,6 +23,9 @@ on: - 'tests/config/**' - 'tests/tui/**' - 'config/sidechain/**' + - 'config/role-accounts.json' + - 'config/lib/**' + - 'config/entrypoint-unified.sh' - 'scripts/dream-inbox.mjs' - 'management-api/lib/system-manifest.js' - '.github/workflows/manifest-validate.yml' @@ -44,6 +47,9 @@ on: - 'management-api/lib/system-manifest.js' - 'tests/config/knots_header_v2.py' - 'config/sidechain/**' + - 'config/role-accounts.json' + - 'config/lib/**' + - 'config/entrypoint-unified.sh' - '.github/workflows/manifest-validate.yml' concurrency: @@ -124,6 +130,27 @@ jobs: - name: Assert JSON Schema is well-formed run: python3 -c "import json; json.load(open('schema/agentbox.toml.schema.json'))" + - name: The two supervisor configs cannot drift (ADR-2122) + # Today's rendered config vs the isolated one derived from it: only the role + # programs' user= and environment= lines differ; secret paths are per-role. + run: bash tests/config/role-isolation-supervisor.test.sh + env: + AGENTBOX_MANIFEST_BIN: services/agentbox-manifest/target/release/agentbox-manifest + + - name: /run/secrets delivery builds the right owner/mode tree (ADR-2122) + # The real plan against a scratch root; chown is a recording stub (no root in CI). + run: bash tests/config/role-secrets-delivery.test.sh + env: + AGENTBOX_MANIFEST_BIN: services/agentbox-manifest/target/release/agentbox-manifest + - name: at-rest custody migrate/revert and sidechain state seed (ADR-2122, design 3.2-3.4, W2b/W4) + # The real registry rows against a scratch root; chown is a recording stub (no root in CI). + run: bash tests/config/role-custody-migrate.test.sh + env: + AGENTBOX_MANIFEST_BIN: services/agentbox-manifest/target/release/agentbox-manifest + + - name: With [security].role_isolation off the boot is today's (ADR-2122) + run: bash tests/config/role-isolation-boot.test.sh + - name: Sealed chain documents are well-formed and gated (ADR-2103) # The block file is not in the repository, so the on-disk hash case skips here; # the document invariants, the P21 mainnet gate and the no-key-in-git check run. diff --git a/AGENTS.md b/AGENTS.md index 849392994..c2b404056 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,13 +65,17 @@ Everything else (tab0-bridge, voice, harness-wrappers, AoE seeder, explainer-too - The sovereign data stack (solid-pod-rs, nostr relay + pod-inbox bridge, identity layer, privacy filter) shares one identity and one source of truth — weigh changes across all four layers (DDD-003 I01–I12). - No host-project specifics in this repo — reference the host by role, not name. - Every adapter dispatch emits span + log + metrics; only exporters are optional. -- Never reintroduce Linux pseudo-user isolation as the primary model. +- Never reintroduce Linux pseudo-user isolation as the primary model. The ADR-2122 role service accounts (`ab-*`, uids 960–979) are not that model: they hold secrets for supervised daemons and never host an agent. +- **One checkout per worker.** Parallel agents each work in their own `git worktree add ` and never share the main checkout or another worker's tree. A shared tree mixes uncommitted edits across branches, and the host half of the role-isolation rehearsal refuses a dirty `scripts/activation`. +- **The two supervisor configs may differ only in `user=` and `environment=`.** `/etc/supervisord.roles.conf` is derived from `/etc/supervisord.conf` by `agentbox-manifest role-accounts isolate` with `config/role-accounts.json`, and `tests/config/role-isolation-supervisor.test.sh` fails on any other difference. Change a role program in `flake.nix`'s supervisor text, never in the derived file. A new secret-bearing program or a new `[sidechain.]` chain needs a role in the table, or the build fails. A future `role-exec` wrapper must widen the allowed set to `command=` explicitly. ## Runtime model gotchas - Deploy with `./agentbox.sh rebuild` (`--no-cleanup` keeps recovery images). It loads `docker-compose.override.yml` (workspace volume, project mounts, `.env`); a base-only `docker compose -f docker-compose.yml` exposes an unrelated workspace bind — never do that; inspect mounts after deploy. - `HOME=/home/devuser`; workspace `/home/devuser/workspace` (`$WORKSPACE`). The literal `/workspace` is retired and will break. -- Supervisord runs as PID 1 root; every long-running program drops to `user=devuser`. Docs describing `gemini-user`/`openai-user` pseudo-users are legacy. +- Supervisord runs as PID 1 root; every long-running program drops to `user=devuser`. Docs describing `gemini-user`/`openai-user` pseudo-users are legacy. The exception is `[security].role_isolation = true` (ADR-2122: boot-class, default off, staged until its rehearsal passes). The boot then execs `/etc/supervisord.roles.conf`, which runs `nostr-relay` and `serve-identity` as `ab-identity` and runs the gateway, the nip98-proxy and the sidestr producers and faucets as their own roles. Role secrets are delivered to the root-owned tmpfs `/run/secrets//`, ROLE env vars leave PID 1, and devuser's Docker CLI becomes GET-only (`/run/docker-ro.sock`; no `docker exec` from inside). Owner procedure: [role-isolation-runbook](docs/developer/role-isolation-runbook.md). +- `[program:serve-identity]` (the identity port) and `[program:docker-read-proxy]` print one line and exit 0 while the flag is off. `EXITED` is their expected status, not a fault. +- The browser sidecar runs `[program:podkey-loader]` (`browsercontainer/supervisord.conf`), which loads the pinned Podkey into Chrome once per Chrome process. K_browser lives in Podkey's vault on the `browsercontainer-profile` volume. The owner mints it over VNC and unlocks it after every sidecar restart with `./agentbox.sh browsercontainer podkey unlock`. An agent only ever sees the public key (`podkey pubkey`). See `browsercontainer/README.md`. - AoE sessions persist on the `aoe-profiles` volume; the seeder reaps only its own clean orphan worktrees (ADR-2063). Hub-routed MCP servers refusing connections → `supervisorctl status agentbox-mcp-hub` first (it parks FATAL after 120 s without `/run/agentbox/mcp-hub.json`, ADR-2104). - Pods requests are unsigned (`sign_requests = false`, ADR-2064) until ADR-2078 wires the identity key; flipping it without key material throws `SigningUnavailable`. - Claude Fable 5.1 uses always-on adaptive thinking: keep API histories append-only, replay thinking blocks exactly, do not force tool choice. diff --git a/README.md b/README.md index 0f347d68f..780f5c5e5 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,9 @@ Each sibling in its own words: sidestr-rs — Rust sidechain engine: a did:nostr key is a sidechain wallet The optional `[sidechain]` gates supervise the interim testnet producer, public Pages mirror -and standalone Rust faucet. Setup defaults are off; keys, pinned upstream checkouts and -persistent chain/grant state must already exist. See [sidechain operations](config/sidechain/README.md). +and standalone Rust faucet. Setup defaults are off; keys and persistent chain/grant state must +already exist. The producer runs the image's read-only bake of the pinned upstream +(`lib/sidestr-upstream.nix`), not a workspace checkout. See [sidechain operations](config/sidechain/README.md).
> **sidestr-rs — Rust port of Melvin Carvalho's sidestr sidechains, AGPL-3.0-only: the economic engine for did:nostr agents. A did:nostr key is a sidechain wallet.** Five published crates (`sidestr-header`, `-core`, `-nostr`, `-wallet`, `-round`) are attributed ports of upstream `siding`, proven byte-for-byte against the JavaScript reference and audited before every publish. Agentbox runs the estate's own chain, `sidestr:dreamlab`, on Bitcoin testnet4 (interim producer: the upstream JS engine) and takes the crates from crates.io when its Rust node lands. Experimental, testnet only; no real funds anywhere. diff --git a/agentbox.sh b/agentbox.sh index ea2506326..d65de764b 100755 --- a/agentbox.sh +++ b/agentbox.sh @@ -1450,12 +1450,25 @@ CONCAT_HELP # browsercontainer lifecycle # --------------------------------------------------------------------------- +# W9: the sidecar image ships the pinned Podkey CI artefact (browsercontainer/podkey.pin). +# Fetch it into the build context before any build; a cached zip that already +# matches the pin is reused, so this needs gh or GH_TOKEN only on first fetch or +# after the pin moves. +browsercontainer_fetch_podkey() { + if ! "${SCRIPT_DIR}/browsercontainer/scripts/fetch-podkey.sh" fetch; then + echo -e "${RED}Cannot obtain the pinned Podkey artefact; the browsercontainer image is not built without it.${NC}" + echo " Authenticate gh (gh auth login) or export GH_TOKEN, then retry. See browsercontainer/README.md (Podkey)." + exit 1 + fi +} + cmd_browsercontainer() { local subcmd="${1:-help}" shift 2>/dev/null || true case "$subcmd" in up) + browsercontainer_fetch_podkey echo -e "${CYAN}Building and starting browsercontainer...${NC}" docker compose "${SIDECAR_COMPOSE_ARGS[@]}" up -d --build local deadline=$(( $(date +%s) + 60 )) @@ -1499,10 +1512,25 @@ cmd_browsercontainer() { ;; rebuild) echo -e "${CYAN}Rebuilding browsercontainer...${NC}" + browsercontainer_fetch_podkey docker compose "${SIDECAR_COMPOSE_ARGS[@]}" down docker compose "${SIDECAR_COMPOSE_ARGS[@]}" build --no-cache cmd_browsercontainer up ;; + podkey) + # W9: the sidecar's Podkey (pinned CI artefact). Never handles a private key: + # status/pubkey read state and the public key; unlock prompts for the vault + # passphrase on this terminal (no echo) for the current Chrome session. + local pk_cmd="${1:-status}" + case "$pk_cmd" in + status|pubkey|id) + docker exec --user 1000 browsercontainer node /opt/browsercontainer/podkey-ctl.js "$pk_cmd" ;; + unlock) + docker exec -it --user 1000 browsercontainer node /opt/browsercontainer/podkey-ctl.js unlock ;; + *) + echo "Usage: $0 browsercontainer podkey [status|pubkey|unlock|id]"; exit 2 ;; + esac + ;; shell) docker exec -it --user 1000 browsercontainer bash ;; @@ -1540,6 +1568,7 @@ Usage: $0 browsercontainer ${GREEN}status${NC} Show container status ${GREEN}rebuild${NC} Full rebuild (down + build --no-cache + up) ${GREEN}shell${NC} Open bash in the container + ${GREEN}podkey${NC} Podkey in the sidecar: status | pubkey | unlock | id ${GREEN}gpu${NC} Check GPU and Vulkan status inside container ${GREEN}cdp${NC} Check CDP connectivity and list browser tabs diff --git a/agentbox.toml b/agentbox.toml index b06199af4..51bcc2fbf 100644 --- a/agentbox.toml +++ b/agentbox.toml @@ -157,7 +157,7 @@ allowed_pubkeys = [ # Populated 2026-07-22 from the dreamlab-ai D1 whitelist + forum-config (Tock-0 staging; # baked at nix build — takes effect at the next image rebuild). "6407eed80e2a8646e41a5ddba0ae6619425fc54af40e2b30482b9623c682425a", # operator-jjohare — human admin (Dr John O'Hare) - "b41654017f6850b13857d19d8ae0e3f88f1365600cab0321e8101c9e92682f7a", # visionclaw-server — governance publisher (key-split pending, ADR-040 D3) + "b41654017f6850b13857d19d8ae0e3f88f1365600cab0321e8101c9e92682f7a", # operator NIP-07 signer — signs 31403 governance decisions (Q15, 2026-10-03: NOT visionclaw-server, which signs 31402 as 11ed6422…; G-5) "cd37af940054d7b2dc979b6f51296db1ca2c34d2a234f35bc6a84888a69a1f88", # beema — forum admin "a3af4977eb4c93bc7270b9e48cac69e8e6c18bb3bcb4ec3aaeeee360c3efd954", # RedDread — forum admin (house admin; remove if misidentified) "2de44d5622eef79519ac078f6e227a85aecbaefd561e4e50c5f51dfadbf916e9", # junkiejarvis — agentbox-bridge forum agent @@ -1716,8 +1716,8 @@ proxy_port = 9096 # nip98-proxy listen port — the sole IDENT allowed_pubkeys = [ "c32b71bc1d4ab9d25cd0bd2a457ede15e6538d0d6311fc3c0543f9e8ca1bc13c", # agentbox-core on the connected node — peer node (added 2026-09-02, the connected node mesh bring-up) "6407eed80e2a8646e41a5ddba0ae6619425fc54af40e2b30482b9623c682425a", # operator-jjohare (dreamlab roster) - "b41654017f6850b13857d19d8ae0e3f88f1365600cab0321e8101c9e92682f7a", # visionclaw-server governance publisher / active NIP-07 signer - "11ed64225dd5e2c5e18f61ad43d5ad9272d08739d3a20dd25886197b0738663c", # agentbox sovereign / house key (browsercontainer Podkey vault) + "b41654017f6850b13857d19d8ae0e3f88f1365600cab0321e8101c9e92682f7a", # operator NIP-07 signer — 31403 decisions ([sovereign_mesh.operator]); not visionclaw-server (Q15, G-5) + "11ed64225dd5e2c5e18f61ad43d5ad9272d08739d3a20dd25886197b0738663c", # agentbox sovereign / house key (browsercontainer Podkey vault copy to be replaced by K_browser; G-5) ] # Routes beyond the default AoE upstream. bearer_env names an env var whose @@ -2099,6 +2099,20 @@ log_level = "info" # the residual attack surface analysis before flipping this to true. audit_acknowledged = true +# Custody X-1 step 1 (ADR-2122): per-role service accounts. On, role programs +# (nostr-relay, nostr-gateway, nip98-proxy, sidestr producers and faucets) run +# under their own uids from config/role-accounts.json, read their secrets from +# /run/secrets// (root-owned tmpfs, 0500 dir, 0400 files), the classified +# variables leave PID 1's environment, and devuser loses the raw Docker socket +# (W0). Boot-class: the image ships both supervisor configs; restart to apply. +# Leave OFF until the role-isolation rehearsal passes on a rebuilt image +# (scripts/activation/role-isolation-rehearsal.sh). The identity port (W3), the +# at-rest custody migrate/revert (W2b) and the sidechain state move (W4) are +# built but not yet proven in an image, and the W3b consumer cutover is owed: +# until it lands JunkieJarvis, the mirror hook and dream-engine cannot sign +# under the flag. +role_isolation = false + # Feature-specific exceptions to the hardened baseline. # Each key must match an enabled feature gate (validator rule E020). # Add the exception block when you enable the corresponding feature. diff --git a/browsercontainer/.gitignore b/browsercontainer/.gitignore new file mode 100644 index 000000000..c9b355e62 --- /dev/null +++ b/browsercontainer/.gitignore @@ -0,0 +1,2 @@ +# Podkey CI artefact cache: fetched by scripts/fetch-podkey.sh, verified against podkey.pin. +/vendor/ diff --git a/browsercontainer/Dockerfile b/browsercontainer/Dockerfile index 9d9caee96..8dd31af3f 100644 --- a/browsercontainer/Dockerfile +++ b/browsercontainer/Dockerfile @@ -31,6 +31,7 @@ RUN pacman -Syu --noconfirm && pacman -S --noconfirm --needed \ base-devel \ git \ sudo \ + unzip \ && pacman -Scc --noconfirm \ && rm -rf /var/cache/pacman/pkg/* /root/.npm /tmp/* @@ -53,8 +54,11 @@ RUN sudo -u devuser yay -S --noconfirm --needed google-chrome-beta \ && rm -rf /home/devuser/.cache/yay /tmp/* # Application directory -RUN mkdir -p /opt/browsercontainer /tmp/xdg-runtime /tmp/chromium-crashes /tmp/chrome-profile /home/devuser/exchange \ - && chown -R devuser:devuser /opt/browsercontainer /tmp/xdg-runtime /tmp/chromium-crashes /tmp/chrome-profile /home/devuser/exchange +# /home/devuser/chrome-profile is the mount point of the named volume +# browsercontainer-profile (W9); creating it here owned by devuser means a fresh +# volume is initialised with devuser ownership. +RUN mkdir -p /opt/browsercontainer /tmp/xdg-runtime /tmp/chromium-crashes /tmp/browsercontainer /home/devuser/chrome-profile /home/devuser/exchange \ + && chown -R devuser:devuser /opt/browsercontainer /tmp/xdg-runtime /tmp/chromium-crashes /tmp/browsercontainer /home/devuser/chrome-profile /home/devuser/exchange # Install chrome-devtools-mcp and ws globally (runtime uses Chrome Beta from AUR) RUN npm install -g chrome-devtools-mcp@latest ws && rm -rf /root/.npm @@ -69,6 +73,24 @@ COPY --chown=devuser:devuser server.js /opt/browsercontainer/server.js COPY --chown=devuser:devuser launch-chromium.sh /opt/browsercontainer/launch-chromium.sh COPY --chown=devuser:devuser cdp-diagnose.js /opt/browsercontainer/cdp-diagnose.js COPY --chown=devuser:devuser puppeteer-mermaid.json /opt/browsercontainer/puppeteer-mermaid.json +COPY --chown=devuser:devuser podkey-ctl.js /opt/browsercontainer/podkey-ctl.js + +# Podkey (W9, owner rule 2026-10-03): the published `podkey-extension` CI artefact +# of JavaScriptSolidServer/podkey, named exactly by podkey.pin. The zip is fetched +# on the host before the build (`agentbox.sh browsercontainer up|rebuild` runs +# scripts/fetch-podkey.sh fetch), so no token enters the build; here it is only +# verified against the pin and unpacked. Never built from source, never a fork zip. +COPY --chown=devuser:devuser podkey.pin /opt/browsercontainer/podkey.pin +COPY --chown=devuser:devuser scripts/fetch-podkey.sh /opt/browsercontainer/scripts/fetch-podkey.sh +COPY vendor/podkey-extension.zip /tmp/podkey-extension.zip +RUN bash /opt/browsercontainer/scripts/fetch-podkey.sh install --pin /opt/browsercontainer/podkey.pin \ + --zip /tmp/podkey-extension.zip --dest /opt/browsercontainer/extensions/podkey \ + && rm -f /tmp/podkey-extension.zip +# Managed policy: block every extension except Podkey (id derived from the path +# above; podkey-ctl.js `id`). Branded Chrome reads /etc/opt/chrome, Chromium /etc/chromium. +COPY policies/podkey-only.json /etc/opt/chrome/policies/managed/podkey-only.json +RUN install -D -m 0644 /etc/opt/chrome/policies/managed/podkey-only.json /etc/chromium/policies/managed/podkey-only.json + COPY supervisord.conf /etc/supervisord.conf COPY --chown=devuser:devuser healthcheck.sh /opt/browsercontainer/healthcheck.sh RUN chmod +x /opt/browsercontainer/healthcheck.sh /opt/browsercontainer/launch-chromium.sh diff --git a/browsercontainer/README.md b/browsercontainer/README.md index 848ae5dbb..150d91bc1 100644 --- a/browsercontainer/README.md +++ b/browsercontainer/README.md @@ -140,3 +140,100 @@ For Claude Code / agentbox agents, add to `.mcp.json`: } } ``` + +## Podkey and the persistent profile (2026-10-03, custody-isolation W9) + +Owner rule (2026-10-03): the sidecar installs and enables **Podkey** from the +published artefact as a matter of course. The sidecar's identity, K_browser +(G-5), is held by Podkey, not by Proton Pass and not by a throwaway `/tmp` profile. + +### Fresh host (CY-C) + +A fresh host's sidecar ships Podkey with no identity. `agentbox.sh browsercontainer up` +(or `rebuild`) first runs `scripts/fetch-podkey.sh fetch`, which needs an +authenticated `gh` or an exported `GH_TOKEN`/`GITHUB_TOKEN` (GitHub serves +artefacts to authenticated callers only). After that the zip is cached in +`browsercontainer/vendor/` (gitignored) and later builds need no network. The +container comes up healthy with Podkey loaded and `state: none` until the owner +mints K_browser (below). + +### What is installed, and from where + +| Item | Value | +|---|---| +| Source | `podkey-extension` artefact of `JavaScriptSolidServer/podkey` CI (`.github/workflows/ci.yml`); there are no GitHub releases | +| Pin | `browsercontainer/podkey.pin`: commit, run id, artefact id, zip sha256, expected name and version | +| Fetch | host side, raw zip via `GET /repos/…/actions/artifacts/{id}/zip` (`gh api`, or curl + token). Before downloading, the run's `head_sha` must equal the pinned commit and the artefact must belong to the pinned run. `gh run download` is not used because it unpacks, so the zip bytes the pin covers would never be seen | +| Verify | the zip's sha256 must equal the pin (refused otherwise, exit 3). Inside the image build, `fetch-podkey.sh install` re-verifies, refuses `..`/absolute entries, and checks `manifest.json` name/version and `build.json` commit against the pin | +| Install path | `/opt/browsercontainer/extensions/podkey` (inside the image) | +| Extension id | `cakgjkgiodcdhecnnfhmcfphjkknfjkd`. The artefact's manifest has no `key`, so Chrome derives the id from the absolute path: the first 16 bytes of SHA-256(path), each hex nibble mapped `0-f` to `a-p`. The path is fixed in the image, so the id is stable across rebuilds and hosts. `node /opt/browsercontainer/podkey-ctl.js id` prints it | +| Policy | `/etc/opt/chrome/policies/managed/podkey-only.json` (and `/etc/chromium/…`): `ExtensionInstallBlocklist ["*"]`, `ExtensionInstallAllowlist []` | + +**Moving the pin forward** (the only way it changes; rule-estate-pins-move-forward): +pick a green `ci.yml` run on podkey `main`, then edit `podkey.pin` with its `head_sha`, +run id, the `podkey-extension` artefact id, the artefact's `digest` (that is the +zip sha256), the manifest version and `expires_at`. Then `rebuild`. CI artefacts +expire after 90 days. The current pin expires **2026-12-24**, after which only +hosts with a cached zip can build until the pin moves. + +### How Chrome loads it + +| Browser | Flags | Load | +|---|---|---| +| Google Chrome beta (default) | `--enable-unsafe-extension-debugging` | `podkey-loader` (supervisord) calls CDP `Extensions.loadUnpacked` | +| Chromium (fallback) | `--load-extension= --disable-extensions-except=` | Chrome itself | +| no extension dir | none | Chrome starts without Podkey (fail-open) | + +Branded Chrome 137+ ignores `--load-extension`. On Chrome beta 151, +`--disable-extensions-except` still applies and disables even the CDP-loaded +Podkey. Both were observed in this sidecar on 2026-10-03, which is why branded +Chrome gets neither flag and the managed policy enforces "Podkey only" instead. +The loader loads **once per Chrome process**: re-loading an unpacked extension +clears `chrome.storage.session`, which would silently lock an unlocked key. + +### The profile volume + +Chrome's `--user-data-dir` is `/home/devuser/chrome-profile`, the named volume +**`browsercontainer-profile`** (stable name, independent of the compose project). +It holds Podkey's `chrome.storage.local`: the **encrypted vault** +(scrypt + AES-256-GCM, sealed under the owner's passphrase), the public key, and +site grants. The plaintext private key exists only in `chrome.storage.session` +(memory) while unlocked. Never run `docker compose -f docker-compose.browsercontainer.yml down -v`. +That deletes the volume and, with it, the sealed K_browser. + +`/tmp/chrome-profile` is gone. Extensions installed by hand into it (Proton Pass +was added from the Chrome Web Store via VNC on 2026-09-28 13:03 UTC; it was never +part of this build) are not carried over, and the policy blocks re-installing them. + +### Owner steps + +**Mint K_browser (once, after the first rebuild with this change)** + +1. `./agentbox.sh browsercontainer podkey status` should show `"state": "none"` + and `"extension_id": "cakgjkgiodcdhecnnfhmcfphjkknfjkd"`. +2. Open VNC (`vnc://:5903`). In Chrome, open + `chrome-extension://cakgjkgiodcdhecnnfhmcfphjkknfjkd/popup/popup.html` (or click + Podkey's toolbar icon). Choose **Generate new key**, using the passphrase path: + the sidecar has no security key for the passkey path. Pick a passphrase of at + least 8 characters and keep it outside the sidecar. +3. Back up per D3 at mint time: **Export key** in Podkey's popup, then seal the + nsec with your age key on your own machine. It never goes into this repository + or the agent's view. +4. `./agentbox.sh browsercontainer podkey pubkey` prints the 64-hex public key. + Give it to the agent, which puts it in `config/custody/g5-key-split.json` + (`k_browser.pubkey`) and `[interaction_plane.proxy].allowed_pubkeys` (G-5 step 2). + +**After every sidecar restart or recreate** the vault is still on the volume, but the +session key is gone, so Podkey is `locked`. Re-unlock (no re-import): + +```bash +./agentbox.sh browsercontainer podkey unlock # prompts for the passphrase, no echo +``` + +or type it into Podkey's unlock screen over VNC. Unlock is deliberately not part +of the start script: doing it unattended would mean storing the passphrase in the +sidecar, which defeats the vault. + +**Only if the volume is lost:** Podkey shows `state: none`. Restore from the age +backup, then use **Import existing key** in the popup over VNC, paste the nsec, and +set a passphrase. The public key, and so every verifier, is unchanged. diff --git a/browsercontainer/launch-chromium.sh b/browsercontainer/launch-chromium.sh index 817ae166d..f06413dca 100644 --- a/browsercontainer/launch-chromium.sh +++ b/browsercontainer/launch-chromium.sh @@ -3,6 +3,22 @@ # Launched by supervisord; MCP server attaches via CDP on port 9222 # socat proxy on 9223 exposes CDP to the Docker host # Prefers google-chrome-beta (149+, WebMCP), falls back to chromium +# +# Podkey (W9, owner rule 2026-10-03): the pinned Podkey CI artefact is unpacked +# at $PODKEY_EXT_DIR at image build (scripts/fetch-podkey.sh). How it is loaded +# depends on the browser, and this script records which in $RUN_DIR/podkey-mode +# for podkey-ctl.js (supervisord program podkey-loader): +# Chromium --load-extension + --disable-extensions-except mode=flag +# Google Chrome branded Chrome 137+ ignores --load-extension, and on 151 +# --disable-extensions-except disables even a CDP-loaded +# extension, so neither flag is passed; podkey-loader calls +# Extensions.loadUnpacked (needs +# --enable-unsafe-extension-debugging); the managed policy +# policies/podkey-only.json allowlists only Podkey mode=cdp +# no extension dir Chrome starts without Podkey (fail-open) mode=none +# The profile ($CHROME_PROFILE_DIR) is the named volume browsercontainer-profile, +# so Podkey's chrome.storage.local (encrypted vault, pubkey, grants) survives +# container recreation. SECURE_ORIGINS="${TREAT_AS_SECURE:-http://the model host:3001,http://the model host:3000,http://host.docker.internal:3001,http://host.docker.internal:3000}" @@ -21,6 +37,10 @@ if [ -z "$CHROME_BIN" ]; then exit 1 fi +PROFILE_DIR="${CHROME_PROFILE_DIR:-/home/devuser/chrome-profile}" +PODKEY_EXT_DIR="${PODKEY_EXT_DIR:-/opt/browsercontainer/extensions/podkey}" +RUN_DIR="${BROWSERCONTAINER_RUN_DIR:-/tmp/browsercontainer}" + echo "[launch-chromium] Using: $CHROME_BIN" >&2 echo "[launch-chromium] TREAT_AS_SECURE: $SECURE_ORIGINS" >&2 @@ -31,8 +51,34 @@ echo "[launch-chromium] TREAT_AS_SECURE: $SECURE_ORIGINS" >&2 # on Chrome 149 even though SharedArrayBuffer worked. Use the single-flag # comma form. +# The profile survives container recreation; a Singleton* left by the previous +# container (same hostname, dead pid) would make Chrome refuse the profile as +# "in use". Only this script starts Chrome in this container, so they are stale. +mkdir -p "$PROFILE_DIR" "$RUN_DIR" +rm -f "$PROFILE_DIR/SingletonLock" "$PROFILE_DIR/SingletonSocket" "$PROFILE_DIR/SingletonCookie" + +EXTENSION_ARGS=() +PODKEY_MODE=none +if [ -f "$PODKEY_EXT_DIR/manifest.json" ]; then + case "$("$CHROME_BIN" --version 2>/dev/null)" in + "Google Chrome"*) + PODKEY_MODE=cdp + EXTENSION_ARGS=(--enable-unsafe-extension-debugging) + ;; + *) + PODKEY_MODE=flag + EXTENSION_ARGS=(--load-extension="$PODKEY_EXT_DIR" --disable-extensions-except="$PODKEY_EXT_DIR") + ;; + esac +else + echo "[launch-chromium] WARN: no Podkey at $PODKEY_EXT_DIR; starting without it" >&2 +fi +echo "$PODKEY_MODE" >"$RUN_DIR/podkey-mode" || true +echo "[launch-chromium] Podkey load mode: $PODKEY_MODE" >&2 + exec "$CHROME_BIN" \ - --user-data-dir=/tmp/chrome-profile \ + --user-data-dir="$PROFILE_DIR" \ + "${EXTENSION_ARGS[@]}" \ --no-first-run \ --no-default-browser-check \ --no-sandbox \ diff --git a/browsercontainer/podkey-ctl.js b/browsercontainer/podkey-ctl.js new file mode 100644 index 000000000..ce85ae30f --- /dev/null +++ b/browsercontainer/podkey-ctl.js @@ -0,0 +1,317 @@ +#!/usr/bin/env node +/** + * podkey-ctl — load and inspect the pinned Podkey extension in the sidecar's Chrome. + * + * Why this exists: Google Chrome (branded, 137+) ignores --load-extension, and on + * Chrome beta 151 --disable-extensions-except still applies and disables even an + * extension loaded over CDP (both observed in this sidecar, 2026-10-03). Branded + * Chrome therefore gets Podkey via the CDP call Extensions.loadUnpacked, which + * needs --enable-unsafe-extension-debugging. Chromium still honours the flags, + * and launch-chromium.sh uses them there. launch-chromium.sh records which path it + * took in $RUN_DIR/podkey-mode (cdp | flag | none). + * + * Load exactly once per Chrome process. Re-loading an unpacked extension in the + * same process clears chrome.storage.session, which is where Podkey keeps the + * unlocked private key, so a second load would silently lock it. The browser's + * websocket URL (unique per process) is recorded in $RUN_DIR/podkey-loaded-for, + * so a restarted loader does not reload a live browser. + * + * Commands + * load [--watch] load Podkey (once per Chrome process); --watch keeps following + * Chrome restarts (supervisord program `podkey-loader`) + * status print {extension_id, version, state, publicKey, did} as JSON + * pubkey print the 64-hex public key (exit 1 if there is no identity) + * unlock read the vault passphrase from stdin (no echo on a TTY) and + * unlock Podkey for this browser session + * + * Nothing here creates, imports or exports a private key. The identity is minted + * in Podkey's own popup (owner step, browsercontainer/README.md). status, pubkey + * and unlock speak to Podkey through one of its own pages + * (chrome-extension:///popup/popup.html), because Podkey only accepts key + * operations from its extension UI. + */ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_EXT_DIR = '/opt/browsercontainer/extensions/podkey'; +const EXT_DIR = process.env.PODKEY_EXT_DIR || DEFAULT_EXT_DIR; +const CDP_PORT = parseInt(process.env.CDP_PORT || '9222', 10); +const RUN_DIR = process.env.BROWSERCONTAINER_RUN_DIR || '/tmp/browsercontainer'; +const MODE_FILE = path.join(RUN_DIR, 'podkey-mode'); +const STATE_FILE = path.join(RUN_DIR, 'podkey-loaded-for'); +const POLL_MS = 3000; + +const log = (msg) => process.stderr.write(`[podkey-ctl] ${msg}\n`); + +/** + * Chrome's id for an unpacked extension without a manifest `key`: the first 16 + * bytes of SHA-256 over the absolute path, each hex nibble mapped 0-f -> a-p. + */ +function extensionIdForPath(p) { + const hex = crypto.createHash('sha256').update(p).digest('hex').slice(0, 32); + return hex.replace(/[0-9a-f]/g, (c) => String.fromCharCode(97 + parseInt(c, 16))); +} + +const PODKEY_ID = extensionIdForPath(DEFAULT_EXT_DIR); +const EXT_ID = extensionIdForPath(EXT_DIR); + +function readMode(file = MODE_FILE) { + try { + const m = fs.readFileSync(file, 'utf8').trim(); + return ['cdp', 'flag', 'none'].includes(m) ? m : 'none'; + } catch { return 'none'; } +} + +function readState(file = STATE_FILE) { + try { return fs.readFileSync(file, 'utf8').trim() || null; } catch { return null; } +} + +function writeState(file, key) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${key}\n`); +} + +/** 'wait' (no browser yet) | 'load' | 'skip' */ +function decideLoad({ mode, browserKey, lastLoadedKey }) { + if (!browserKey) return 'wait'; + if (mode !== 'cdp') return 'skip'; + return browserKey === lastLoadedKey ? 'skip' : 'load'; +} + +const HEX64 = /^[0-9a-f]{64}$/; + +/** Reduce a Podkey status/unlock reply to the fields that may be printed. */ +function sanitiseStatus(r) { + if (!r || typeof r !== 'object') return { state: 'error', error: 'no reply from Podkey', publicKey: null, did: null }; + if (r.error) return { state: 'error', error: String(r.error), publicKey: null, did: null }; + const publicKey = typeof r.publicKey === 'string' && HEX64.test(r.publicKey) ? r.publicKey : null; + const state = ['unlocked', 'locked', 'none'].includes(r.state) ? r.state : (publicKey ? 'unlocked' : 'none'); + return { state, publicKey, did: publicKey ? `did:nostr:${publicKey}` : null }; +} + +function statusExpression() { + return "chrome.runtime.sendMessage({ type: 'GET_KEYPAIR_STATUS' })"; +} + +function unlockExpression(passphrase) { + return `chrome.runtime.sendMessage({ type: 'UNLOCK_VAULT', passphrase: ${JSON.stringify(passphrase)} })`; +} + +// ── CDP plumbing ────────────────────────────────────────────────────────────── +const WS = globalThis.WebSocket || require('ws'); + +async function browserWsUrl() { + try { + const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`, { signal: AbortSignal.timeout(2000) }); + return (await res.json()).webSocketDebuggerUrl || null; + } catch { return null; } +} + +function cdpSession(url) { + return new Promise((resolve, reject) => { + const ws = new WS(url); + let next = 1; + const pending = new Map(); + const onMessage = (data) => { + const msg = JSON.parse(typeof data === 'string' ? data : data.toString()); + const p = msg.id && pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result); + }; + const session = { + send(method, params = {}, timeoutMs = 10000) { + const id = next++; + return new Promise((res, rej) => { + const t = setTimeout(() => { pending.delete(id); rej(new Error(`${method} timed out`)); }, timeoutMs); + pending.set(id, { resolve: (v) => { clearTimeout(t); res(v); }, reject: (e) => { clearTimeout(t); rej(e); } }); + ws.send(JSON.stringify({ id, method, params })); + }); + }, + close() { try { ws.close(); } catch { /* already closed */ } }, + }; + if (ws.addEventListener) { + ws.addEventListener('open', () => resolve(session)); + ws.addEventListener('message', (e) => onMessage(e.data)); + ws.addEventListener('error', () => reject(new Error(`cannot connect to ${url}`))); + } else { + ws.on('open', () => resolve(session)); + ws.on('message', onMessage); + ws.on('error', reject); + } + }); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function extensionManifest() { + try { return JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8')); } catch { return null; } +} + +/** Wait for Podkey's service worker target to appear (it starts on install). */ +async function waitForWorker(browser, ms = 10000) { + const prefix = `chrome-extension://${EXT_ID}/`; + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const { targetInfos } = await browser.send('Target.getTargets'); + if (targetInfos.some((t) => t.type === 'service_worker' && t.url.startsWith(prefix))) return true; + await sleep(500); + } + return false; +} + +async function loadOnce(browserKey) { + const browser = await cdpSession(browserKey); + try { + const { id } = await browser.send('Extensions.loadUnpacked', { path: EXT_DIR }); + if (id !== EXT_ID) log(`WARN: Chrome assigned id ${id}, expected ${EXT_ID} (policy allowlist names ${PODKEY_ID})`); + writeState(STATE_FILE, browserKey); + const running = await waitForWorker(browser); + log(running ? `loaded Podkey ${id}; service worker running` : `loaded Podkey ${id}; WARN: service worker not seen within 10s`); + } finally { browser.close(); } +} + +async function cmdLoad(watch) { + let lastLogged = ''; + const note = (m) => { if (m !== lastLogged) { log(m); lastLogged = m; } }; + const deadline = Date.now() + 30000; + for (;;) { + const browserKey = await browserWsUrl(); + const mode = readMode(); + const decision = decideLoad({ mode, browserKey, lastLoadedKey: readState() }); + if (decision === 'wait') { + note(`waiting for Chrome CDP on 127.0.0.1:${CDP_PORT}`); + if (!watch && Date.now() > deadline) { log('Chrome CDP not reachable; giving up'); return 1; } + } else if (decision === 'skip') { + if (mode === 'cdp') note('Podkey already loaded in this Chrome process'); + else if (mode === 'flag') note('Podkey loaded by --load-extension (Chromium); no CDP load'); + else note('launch-chromium.sh started Chrome without Podkey (mode none); nothing to load'); + if (!watch) return 0; + } else if (!extensionManifest()) { + note(`no manifest.json in ${EXT_DIR}; Podkey not installed in this image, nothing to load`); + if (!watch) return 0; + } else { + try { + await loadOnce(browserKey); + lastLogged = ''; + if (!watch) return 0; + } catch (e) { + note(`load failed: ${e.message} (will retry)`); + if (!watch && Date.now() > deadline) return 1; + } + } + await sleep(POLL_MS); + } +} + +/** Open a background tab on Podkey's own popup page, evaluate, close the tab. */ +async function inExtensionPage(expression) { + const browserKey = await browserWsUrl(); + if (!browserKey) throw new Error(`Chrome CDP not reachable on 127.0.0.1:${CDP_PORT}`); + const browser = await cdpSession(browserKey); + let targetId; + try { + ({ targetId } = await browser.send('Target.createTarget', { + url: `chrome-extension://${EXT_ID}/popup/popup.html`, background: true, + })); + const list = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/list`)).json(); + const page = list.find((t) => t.id === targetId); + if (!page || !page.webSocketDebuggerUrl) throw new Error('cannot attach to the Podkey page'); + const tab = await cdpSession(page.webSocketDebuggerUrl); + try { + let ready = false; + for (let i = 0; i < 20 && !ready; i++) { + const { result } = await tab.send('Runtime.evaluate', { + expression: 'typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.id || ""', returnByValue: true, + }); + ready = result.value === EXT_ID; + if (!ready) await sleep(250); + } + if (!ready) throw new Error(`Podkey (${EXT_ID}) is not loaded in this Chrome`); + const { result, exceptionDetails } = await tab.send('Runtime.evaluate', { + expression, awaitPromise: true, returnByValue: true, + }, 60000); + if (exceptionDetails) throw new Error('Podkey page raised an exception'); + return result.value; + } finally { tab.close(); } + } finally { + if (targetId) await browser.send('Target.closeTarget', { targetId }).catch(() => {}); + browser.close(); + } +} + +async function cmdStatus() { + const m = extensionManifest(); + const s = sanitiseStatus(await inExtensionPage(statusExpression())); + process.stdout.write(`${JSON.stringify({ extension_id: EXT_ID, version: m && m.version, ...s }, null, 2)}\n`); + return s.state === 'error' ? 1 : 0; +} + +async function cmdPubkey() { + const s = sanitiseStatus(await inExtensionPage(statusExpression())); + if (!s.publicKey) { log(`no Podkey identity yet (state ${s.state}); mint it in the Podkey popup first`); return 1; } + process.stdout.write(`${s.publicKey}\n`); + return 0; +} + +function readPassphrase() { + return new Promise((resolve, reject) => { + const input = process.stdin; + if (!input.isTTY) { + let buf = ''; + input.setEncoding('utf8'); + input.on('data', (c) => { buf += c; }); + input.on('end', () => resolve(buf.replace(/\r?\n$/, ''))); + input.on('error', reject); + return; + } + process.stderr.write('Podkey passphrase: '); + input.setRawMode(true); + input.setEncoding('utf8'); + let buf = ''; + const onData = (ch) => { + for (const c of ch) { + if (c === '\r' || c === '\n') { done(); return; } + if (c === '\u0003') { input.setRawMode(false); process.stderr.write('\n'); process.exit(130); } + if (c === '\u007f' || c === '\b') buf = buf.slice(0, -1); else buf += c; + } + }; + const done = () => { input.setRawMode(false); input.removeListener('data', onData); input.pause(); process.stderr.write('\n'); resolve(buf); }; + input.on('data', onData); + }); +} + +async function cmdUnlock() { + const passphrase = await readPassphrase(); + if (!passphrase) { log('empty passphrase; nothing sent'); return 2; } + const s = sanitiseStatus(await inExtensionPage(unlockExpression(passphrase))); + if (s.state === 'error') { log(`unlock failed: ${s.error}`); return 1; } + process.stdout.write(`${JSON.stringify({ extension_id: EXT_ID, state: 'unlocked', publicKey: s.publicKey, did: s.did }, null, 2)}\n`); + return 0; +} + +async function main(argv) { + const [cmd, ...rest] = argv; + switch (cmd) { + case 'load': return cmdLoad(rest.includes('--watch')); + case 'status': return cmdStatus(); + case 'pubkey': return cmdPubkey(); + case 'unlock': return cmdUnlock(); + case 'id': process.stdout.write(`${EXT_ID}\n`); return 0; + default: + process.stderr.write('usage: podkey-ctl.js load [--watch] | status | pubkey | unlock | id\n'); + return 2; + } +} + +module.exports = { + DEFAULT_EXT_DIR, PODKEY_ID, extensionIdForPath, decideLoad, readMode, readState, writeState, + sanitiseStatus, statusExpression, unlockExpression, +}; + +if (require.main === module) { + main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { log(e.message); process.exit(1); }); +} diff --git a/browsercontainer/podkey.pin b/browsercontainer/podkey.pin new file mode 100644 index 000000000..3500388dd --- /dev/null +++ b/browsercontainer/podkey.pin @@ -0,0 +1,12 @@ +{ + "repo": "JavaScriptSolidServer/podkey", + "workflow": ".github/workflows/ci.yml", + "artifact": "podkey-extension", + "commit": "3752aa2b7f579ba4414ea21745fb7e826e08406e", + "run_id": "36134082610", + "artifact_id": "10862859494", + "sha256": "e1cfd874b1ae96e4bb419251795eb4c625a8158e3bb37616564d48761b8453cd", + "extension_name": "Podkey", + "version": "0.0.11", + "artifact_expires": "2026-12-24T12:17:30Z" +} diff --git a/browsercontainer/policies/podkey-only.json b/browsercontainer/policies/podkey-only.json new file mode 100644 index 000000000..e82d785b6 --- /dev/null +++ b/browsercontainer/policies/podkey-only.json @@ -0,0 +1,4 @@ +{ + "ExtensionInstallBlocklist": ["*"], + "ExtensionInstallAllowlist": ["cakgjkgiodcdhecnnfhmcfphjkknfjkd"] +} diff --git a/browsercontainer/scripts/fetch-podkey.sh b/browsercontainer/scripts/fetch-podkey.sh new file mode 100755 index 000000000..c6e962ed5 --- /dev/null +++ b/browsercontainer/scripts/fetch-podkey.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# fetch-podkey.sh — fetch, verify and install the pinned Podkey CI artefact. +# +# Owner rule (2026-10-03, rule-sidecar-ships-podkey): the browser sidecar +# installs Podkey from the PUBLISHED artefact, the `podkey-extension` artefact +# of JavaScriptSolidServer/podkey's CI workflow, never a hand-made zip, a fork +# build, or a from-source build. The artefact is named exactly by +# browsercontainer/podkey.pin; the pin moves forward only by editing that file +# (rule-estate-pins-move-forward). +# +# Subcommands +# fetch [--pin F] [--out ZIP] download the pinned artefact zip (host side, +# before `docker compose build`); reuses a cached +# zip that already matches the pin +# verify [--pin F] --zip ZIP check ZIP's sha256 against the pin +# install [--pin F] --zip ZIP --dest DIR verify, unpack, check manifest/build.json against +# the pin, then atomically replace DIR (image build) +# show-pin [--pin F] print the parsed pin as key=value lines +# +# Download path: the raw artefact zip from the REST endpoint +# GET /repos/{repo}/actions/artifacts/{artifact_id}/zip +# via `gh api` (preferred) or curl with GH_TOKEN / GITHUB_TOKEN. Not +# `gh run download`: that unpacks the archive, so the bytes GitHub's own +# artefact digest covers would never be seen, and the pin is the sha256 of the zip. +# Before downloading, the run's head_sha must equal the pinned commit and the +# artefact id must belong to the pinned run under the pinned name. +# +# Exit codes: 0 ok · 2 usage / invalid pin · 3 sha256 mismatch (REFUSED) +# 4 file missing · 5 content does not match the pin · 6 cannot download +set -euo pipefail + +readonly UPSTREAM_REPO="JavaScriptSolidServer/podkey" +readonly UPSTREAM_ARTIFACT="podkey-extension" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PIN="$HERE/../podkey.pin" +OUT="$HERE/../vendor/podkey-extension.zip" +ZIP="" +DEST="" + +log() { printf '[fetch-podkey] %s\n' "$*" >&2; } +die() { local rc="$1"; shift; log "$*"; exit "$rc"; } + +usage() { + sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed -e '/^set -euo/d' -e 's/^# \{0,1\}//' >&2 + exit 2 +} + +[ $# -ge 1 ] || usage +CMD="$1"; shift +while [ $# -gt 0 ]; do + case "$1" in + --pin) PIN="${2:?--pin needs a file}"; shift 2 ;; + --out) OUT="${2:?--out needs a path}"; shift 2 ;; + --zip) ZIP="${2:?--zip needs a path}"; shift 2 ;; + --dest) DEST="${2:?--dest needs a directory}"; shift 2 ;; + -h|--help) usage ;; + *) die 2 "unknown argument: $1" ;; + esac +done + +# ── pin ─────────────────────────────────────────────────────────────────────── +# The pin is flat JSON with string values only, one key per line, so it parses +# without jq (the sidecar image has none) and stays diff-friendly. +pin_get() { + sed -n "s/^[[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$PIN" | head -n1 +} + +load_pin() { + [ -f "$PIN" ] || die 2 "pin file not found: $PIN" + P_REPO="$(pin_get repo)" + P_ARTIFACT="$(pin_get artifact)" + P_COMMIT="$(pin_get commit)" + P_RUN="$(pin_get run_id)" + P_ARTIFACT_ID="$(pin_get artifact_id)" + P_SHA="$(pin_get sha256)" + P_NAME="$(pin_get extension_name)" + P_VERSION="$(pin_get version)" + P_EXPIRES="$(pin_get artifact_expires)" + + [ "$P_REPO" = "$UPSTREAM_REPO" ] \ + || die 2 "pin repo '$P_REPO' is not the upstream $UPSTREAM_REPO; fork artefacts are never installed" + [ "$P_ARTIFACT" = "$UPSTREAM_ARTIFACT" ] \ + || die 2 "pin artifact '$P_ARTIFACT' is not $UPSTREAM_ARTIFACT" + [[ "$P_COMMIT" =~ ^[0-9a-f]{40}$ ]] || die 2 "pin commit is not a 40-hex sha" + [[ "$P_RUN" =~ ^[0-9]+$ ]] || die 2 "pin run_id is not numeric" + [[ "$P_ARTIFACT_ID" =~ ^[0-9]+$ ]] || die 2 "pin artifact_id is not numeric" + [[ "$P_SHA" =~ ^[0-9a-f]{64}$ ]] || die 2 "pin sha256 is not 64 lowercase hex" + [ -n "$P_NAME" ] || die 2 "pin extension_name is empty" + [[ "$P_VERSION" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]] || die 2 "pin version is not a Chrome version string" +} + +sha256_of() { sha256sum "$1" | cut -d' ' -f1; } + +verify_zip() { # + [ -f "$1" ] || die 4 "artefact zip not found: $1" + local got + got="$(sha256_of "$1")" + if [ "$got" != "$P_SHA" ]; then + die 3 "REFUSED: $1 sha256 $got does not match the pin $P_SHA" + fi +} + +# ── unpack helpers (unzip, else bsdtar, else python3) ───────────────────────── +zip_list() { + if command -v unzip >/dev/null 2>&1; then unzip -Z1 "$1" + elif command -v bsdtar >/dev/null 2>&1; then bsdtar -tf "$1" + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import sys,zipfile; print("\n".join(zipfile.ZipFile(sys.argv[1]).namelist()))' "$1" + else die 5 "no unzip, bsdtar or python3 to read the artefact"; fi +} + +zip_extract() { # + if command -v unzip >/dev/null 2>&1; then unzip -q "$1" -d "$2" + elif command -v bsdtar >/dev/null 2>&1; then bsdtar -xf "$1" -C "$2" + else python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$1" "$2"; fi +} + +json_field() { # — first "key": "value" in a small flat JSON file + sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$1" | head -n1 +} + +# ── subcommands ─────────────────────────────────────────────────────────────── +cmd_show_pin() { + load_pin + printf 'repo=%s\nartifact=%s\ncommit=%s\nrun_id=%s\nartifact_id=%s\nsha256=%s\nextension_name=%s\nversion=%s\nartifact_expires=%s\n' \ + "$P_REPO" "$P_ARTIFACT" "$P_COMMIT" "$P_RUN" "$P_ARTIFACT_ID" "$P_SHA" "$P_NAME" "$P_VERSION" "$P_EXPIRES" +} + +cmd_verify() { + [ -n "$ZIP" ] || die 2 "verify needs --zip" + load_pin + verify_zip "$ZIP" + log "verified $ZIP against pin (podkey $P_VERSION @ ${P_COMMIT:0:7})" +} + +cmd_install() { + [ -n "$ZIP" ] && [ -n "$DEST" ] || die 2 "install needs --zip and --dest" + load_pin + [ -f "$ZIP" ] || die 4 "artefact zip not found: $ZIP" + + local parent stage + parent="$(dirname "$DEST")" + mkdir -p "$parent" + stage="$(mktemp -d "$parent/.podkey-stage.XXXXXX")" + # shellcheck disable=SC2064 # expand now: $stage is fixed for this run + trap "rm -rf '$stage'" EXIT + + # Verify the copy we will extract, so the bytes checked are the bytes unpacked. + cp "$ZIP" "$stage/artefact.zip" + verify_zip "$stage/artefact.zip" + + local entry + while IFS= read -r entry; do + case "$entry" in + /*|..|../*|*/../*|*/..) die 5 "REFUSED: artefact entry escapes the extension dir: $entry" ;; + esac + done < <(zip_list "$stage/artefact.zip") + + mkdir "$stage/ext" + zip_extract "$stage/artefact.zip" "$stage/ext" + + local manifest="$stage/ext/manifest.json" build="$stage/ext/build.json" name version commit + [ -f "$manifest" ] || die 5 "artefact has no manifest.json" + name="$(json_field "$manifest" name)" + version="$(json_field "$manifest" version)" + [ "$name" = "$P_NAME" ] || die 5 "manifest name '$name' is not the pinned '$P_NAME'" + [ "$version" = "$P_VERSION" ] || die 5 "manifest version '$version' is not the pinned '$P_VERSION'" + [ -f "$build" ] || die 5 "artefact has no build.json (CI stamps the source commit there)" + commit="$(json_field "$build" commit)" + [ "$commit" = "$P_COMMIT" ] || die 5 "build.json commit '$commit' is not the pinned $P_COMMIT" + + chmod -R a+rX "$stage/ext" + rm -rf "$DEST" + mv "$stage/ext" "$DEST" + log "installed podkey $version @ ${commit:0:7} into $DEST" +} + +gh_usable() { command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; } + +cmd_fetch() { + load_pin + mkdir -p "$(dirname "$OUT")" + + if [ -f "$OUT" ] && [ "$(sha256_of "$OUT")" = "$P_SHA" ]; then + log "cached $OUT already matches the pin; no download" + return 0 + fi + [ -f "$OUT" ] && { log "cached $OUT does not match the pin; replacing"; rm -f "$OUT"; } + + if [ -n "$P_EXPIRES" ] && [[ "$P_EXPIRES" < "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ]]; then + log "WARNING: the pinned artefact expired at $P_EXPIRES; GitHub will 410 it. Move the pin forward." + fi + + local api="repos/$P_REPO/actions" part head meta + part="$(mktemp "$(dirname "$OUT")/.podkey-download.XXXXXX")" + # shellcheck disable=SC2064 + trap "rm -f '$part'" EXIT + + if gh_usable; then + head="$(gh api "$api/runs/$P_RUN" --jq .head_sha)" || die 6 "cannot read run $P_RUN" + meta="$(gh api "$api/artifacts/$P_ARTIFACT_ID" --jq '.name+" "+(.workflow_run.id|tostring)+" "+(.expired|tostring)')" \ + || die 6 "cannot read artefact $P_ARTIFACT_ID" + [ "$head" = "$P_COMMIT" ] || die 5 "run $P_RUN built $head, not the pinned $P_COMMIT" + [ "$meta" = "$P_ARTIFACT $P_RUN false" ] \ + || die 5 "artefact $P_ARTIFACT_ID is '$meta', expected '$P_ARTIFACT $P_RUN false'" + gh api "$api/artifacts/$P_ARTIFACT_ID/zip" >"$part" || die 6 "download of artefact $P_ARTIFACT_ID failed" + else + local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [ -z "$token" ] || ! command -v curl >/dev/null 2>&1; then + die 6 "cannot download: need an authenticated gh, or curl with GH_TOKEN/GITHUB_TOKEN exported (GitHub serves artefacts to authenticated callers only)" + fi + local hdr=(-H "Authorization: Bearer $token" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28") + head="$(curl -fsS "${hdr[@]}" "https://api.github.com/$api/runs/$P_RUN" | json_field /dev/stdin head_sha)" \ + || die 6 "cannot read run $P_RUN" + [ "$head" = "$P_COMMIT" ] || die 5 "run $P_RUN built $head, not the pinned $P_COMMIT" + meta="$(curl -fsS "${hdr[@]}" "https://api.github.com/$api/artifacts/$P_ARTIFACT_ID")" || die 6 "cannot read artefact $P_ARTIFACT_ID" + [ "$(printf '%s' "$meta" | json_field /dev/stdin name)" = "$P_ARTIFACT" ] \ + || die 5 "artefact $P_ARTIFACT_ID is not named $P_ARTIFACT" + printf '%s' "$meta" | tr -d ' \n' | grep -q "\"workflow_run\":{\"id\":$P_RUN," \ + || die 5 "artefact $P_ARTIFACT_ID does not belong to run $P_RUN" + curl -fsSL "${hdr[@]}" "https://api.github.com/$api/artifacts/$P_ARTIFACT_ID/zip" -o "$part" \ + || die 6 "download of artefact $P_ARTIFACT_ID failed" + fi + + verify_zip "$part" + mv "$part" "$OUT" + log "fetched and verified podkey $P_VERSION @ ${P_COMMIT:0:7} -> $OUT" +} + +case "$CMD" in + fetch) cmd_fetch ;; + verify) cmd_verify ;; + install) cmd_install ;; + show-pin) cmd_show_pin ;; + -h|--help|help) usage ;; + *) die 2 "unknown subcommand: $CMD (fetch|verify|install|show-pin)" ;; +esac diff --git a/browsercontainer/supervisord.conf b/browsercontainer/supervisord.conf index 0af7f501b..3fbf4f5ee 100644 --- a/browsercontainer/supervisord.conf +++ b/browsercontainer/supervisord.conf @@ -50,6 +50,23 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +[program:podkey-loader] +; W9: loads the pinned Podkey into branded Chrome over CDP, once per Chrome +; process (a reload would clear Podkey's session key). No-op on Chromium (loaded +; by --load-extension) or when the image has no Podkey. Never blocks Chrome. +command=node /opt/browsercontainer/podkey-ctl.js load --watch +user=devuser +autostart=true +autorestart=true +priority=27 +startsecs=5 +startretries=1000 +environment=HOME="/home/devuser",CDP_PORT="9222",BROWSERCONTAINER_RUN_DIR="/tmp/browsercontainer" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + [program:cdp-proxy] command=socat TCP-LISTEN:9223,fork,reuseaddr,bind=0.0.0.0 TCP:127.0.0.1:9222 user=devuser diff --git a/config/bake-devuser-privilege.sh b/config/bake-devuser-privilege.sh new file mode 100755 index 000000000..f8e70929d --- /dev/null +++ b/config/bake-devuser-privilege.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# bake-devuser-privilege.sh +# +# Custody X-1 (W10, ADR-2122): devuser's sudo route, baked by flake.nix into the +# image's read-only /etc with the BUILD-time [security].role_isolation. +# +# 0 (off, today's image): /etc/group is left exactly as the flake's heredoc +# wrote it (wheel:x:998:devuser); /etc/sudoers holds root and the +# includedir; /etc/sudoers.d/devuser grants devuser NOPASSWD: ALL. +# 1 (on): devuser is removed from the member lists of root (gid 0) and wheel, +# other members kept; /etc/sudoers is the same two lines; NO drop-in, so no +# sudoers line grants devuser anything. +# +# Runs after the /etc/group heredoc. The boot-time half is +# _ab_devuser_privilege_check in config/entrypoint-unified.sh: an image baked +# with 0 and booted with the flag on cannot be repaired at runtime (/etc is +# read-only), so the boot records degraded:devuser-sudo until a rebuild. +# Test: tests/runtime-contract/RC-X1-07.sh. +set -eu + +etc="${1:?usage: bake-devuser-privilege.sh <0|1>}" +iso="${2:?usage: bake-devuser-privilege.sh <0|1>}" +case "$iso" in + 0|1) ;; + *) echo "bake-devuser-privilege: role_isolation must be 0 or 1, got '$iso'" >&2; exit 2 ;; +esac +[ -f "$etc/group" ] || { echo "bake-devuser-privilege: $etc/group is missing (run after the group heredoc)" >&2; exit 2; } + +if [ "$iso" = 1 ]; then + tmp="$etc/.group.bake.$$" + awk -F: -v OFS=: ' + ($1 == "wheel" || $3 == "0") { + n = split($4, m, ","); out = "" + for (i = 1; i <= n; i++) if (m[i] != "devuser" && m[i] != "") out = out (out == "" ? "" : ",") m[i] + $4 = out + } + { print }' "$etc/group" >"$tmp" + mv -f -- "$tmp" "$etc/group" +fi + +printf 'root ALL=(ALL) ALL\n#includedir /etc/sudoers.d\n' >"$etc/sudoers" +chmod 440 "$etc/sudoers" +mkdir -p "$etc/sudoers.d" +if [ "$iso" = 0 ]; then + printf 'devuser ALL=(ALL) NOPASSWD: ALL\n' >"$etc/sudoers.d/devuser" + chmod 440 "$etc/sudoers.d/devuser" +fi diff --git a/config/custody/env-classes.json b/config/custody/env-classes.json new file mode 100644 index 000000000..06190e963 --- /dev/null +++ b/config/custody/env-classes.json @@ -0,0 +1,879 @@ +{ + "$comment": "Custody X-1 step 1, W2 (bypass 3). Every env var NAME the boot path and the role-secret consumers read, in exactly one class. Names only, never values. Checked by scripts/ci/env-secret-inventory.js --check; read by tests/security/env-secret-inventory.node-test.js. The entrypoint's _AB_ROLE_ENV_VARS list must equal the ROLE set (NAME:role).", + "schema": "agentbox/custody-env-classes/v1", + "updated": "2026-10-03", + "flag": "[security].role_isolation (boot-class, default false)", + "sources": [ + "config/entrypoint-unified.sh", + "config/lib/role-custody.sh", + "flake.nix", + "docker-compose*.yml", + "management-api/**/*.js|.cjs|.mjs", + "config/hooks/nostr-live-mirror.cjs", + "config/hooks/project-tracking-publish.cjs", + "config/hooks/lib/operator-key.cjs", + "config/nostr-gateway/gateway.cjs", + "config/nostr-gateway/nostr-send.cjs", + "config/nip98-proxy/proxy.mjs", + "services/nostr-pod-bridge/src/**/*.rs", + "services/dream-engine/src/relay.rs", + "services/dream-engine/src/governance.rs", + "scripts/dream-forum-suggestions.mjs", + "scripts/run-junkiejarvis.cjs", + "scripts/run-per-user-agent.cjs", + "scripts/experiments/exp-b8-label-log.cjs" + ], + "sources_exclude": [ + "(^|/)tests?/", + "__tests__/", + "\\.test\\.(c|m)?js$", + "\\.spec\\.(c|m)?js$" + ], + "classes": { + "ROLE": { + "AGENTBOX_AGENT_PRIVKEY_HEX": { + "role": "ab-identity", + "reason": "Per-agent DID key override (agent-identity.js loadOrMint); the keyring id is the profile.", + "file_var": "AGENTBOX_AGENT_PRIVKEY_HEX_FILE" + }, + "AGENTBOX_BRIDGE_SK": { + "role": "ab-identity", + "reason": "The same sovereign key as hex, written to identity.env for the relay daemon. Already moved to /run/secrets/nostr.key and unset before supervisord (SEC-003); kept here so the scrub and the loaders treat it like the others.", + "file_var": "AGENTBOX_BRIDGE_SK_FILE" + }, + "AGENTBOX_NSEC": { + "role": "ab-identity", + "reason": "The same sovereign key, bech32. Operator pin read by the bootstrap; identity.env exported it to PID 1 (bootstrap.rs render_runtime_env) and git-bridge signs with it.", + "file_var": "AGENTBOX_NSEC_FILE" + }, + "AGENTBOX_PRIVKEY_HEX": { + "role": "ab-identity", + "reason": "Operator pin of the sovereign key (64-hex); wins over the identity file (identity.rs ensure_identity). Read by the bootstrap, the live-mirror hook, the nostr-gateway and nostr-send.", + "file_var": "AGENTBOX_PRIVKEY_HEX_FILE" + }, + "AGENT_PRIVKEY_HEX": { + "role": "ab-identity", + "reason": "Per-user agent key handed to scripts/run-per-user-agent.cjs.", + "file_var": "AGENT_PRIVKEY_HEX_FILE" + }, + "CONCIERGE_PRIVKEY_HEX": { + "role": "ab-identity", + "reason": "Legacy alias of the JunkieJarvis key (junkiejarvis-agent.js, dream-forum-suggestions, run-per-user-agent).", + "file_var": "CONCIERGE_PRIVKEY_HEX_FILE" + }, + "JUNKIEJARVIS_PRIVKEY_HEX": { + "role": "ab-identity", + "reason": "JunkieJarvis forum agent key (keyring id junkiejarvis): management-api JJ agent, dream-engine governance and digest, dream-forum-suggestions, run-junkiejarvis, run-per-user-agent fallback.", + "file_var": "JUNKIEJARVIS_PRIVKEY_HEX_FILE" + }, + "NIP98_PROXY_ALLOW_BEARER": { + "role": "ab-ingress", + "reason": "The LAN-door break-glass bearer at the nip98-proxy (ADR-2027). W1 delivers it to ab-ingress (config/role-accounts.json). 2026-10-03: it is still the SAME VALUE as BRIDGE_TOKEN (management-api/lib/system-manifest.js:79), which stays devuser-class, so isolating this copy protects nothing until the Q4 split mints a separate bearer.", + "file_var": "NIP98_PROXY_ALLOW_BEARER_FILE" + }, + "NIP98_PROXY_SESSION_SECRET": { + "role": "ab-ingress", + "reason": "NIP-07 session-cookie HMAC secret of the nip98-proxy; W1 delivers it to ab-ingress (design §2.4).", + "file_var": "NIP98_PROXY_SESSION_SECRET_FILE" + }, + "OPERATOR_NOSTR_PRIVKEY": { + "role": "ab-identity", + "reason": "Operator key fallback for the live mirror, the nostr-gateway and nostr-send.", + "file_var": "OPERATOR_NOSTR_PRIVKEY_FILE" + }, + "TAILSCALE_AUTHKEY": { + "role": "root", + "reason": "Tailnet join key. Its only reader is the root tailscale-up program, which passed it on argv; under the flag it gets the key as --authkey=file:.", + "file_var": "TAILSCALE_AUTHKEY_FILE" + } + }, + "DEVUSER_CLASS": { + "AGENTBOX_ACTION_PIPELINE_SECRET": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Capability-token HMAC secret held by management-api, which stays devuser." + }, + "AGENTBOX_EMAIL_GATEWAY_TOKEN": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (email gateway MCP). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "ANTHROPIC_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Anthropic). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "BRAVE_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Brave search). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "BRIDGE_TOKEN": { + "exception": "Q8; ADR-2027 (proposed) rotation and break-glass lifecycle; Q4 split", + "reason": "tab0-bridge bearer (devuser drives tmux, so the token adds nothing against devuser). 2026-10-03: it ALSO doubles as the LAN-door break-glass bearer: voice up copies it into NIP98_PROXY_ALLOW_BEARER (management-api/lib/system-manifest.js:79). That copy is ROLE (ab-ingress) under the flag, but this one stays ambient, so the break-glass credential stays reachable by devuser until Q4 splits the bearer. Recorded, not redesigned here; the lifecycle is ADR-2027 (proposed)." + }, + "CLOUDFLARE_TUNNEL_TOKEN": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Cloudflare tunnel credential." + }, + "CODE_SERVER_PASSWORD": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "code-server login; the IDE runs as devuser." + }, + "CONTEXT7_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Context7). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "CRATES_TOKEN": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "crates.io publish credential used by agents (owner rule: publish clean-room crates)." + }, + "DEEPSEEK_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (DeepSeek). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "GEMINI_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Google Gemini). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "GITHUB_TOKEN": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "GitHub credential; agents push with it (sidestr-mirror too)." + }, + "GOOGLE_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Google). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "GOOGLE_GEMINI_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Google Gemini). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "HF_TOKEN": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Hugging Face). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "JUNKIEJARVIS_LLM_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (JunkieJarvis LLM endpoint). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "JUPYTER_TOKEN": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Jupyter login token; the notebook runs as devuser." + }, + "MANAGEMENT_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "management-api bearer. management-api stays devuser (design §1); run-per-user-agent and agents call it." + }, + "OPENAI_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (OpenAI). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "OPENROUTER_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (OpenRouter). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "PERPLEXITY_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Perplexity). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "POSTGRES_PASSWORD": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Postgres credential; RuVector clients run as devuser." + }, + "RUVECTOR_PG_CONNINFO": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "RuVector Postgres connection string (carries the password); dream-engine and memory tools." + }, + "RUVECTOR_PG_PASSWORD": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "RuVector Postgres password; memory tools run as devuser." + }, + "SOLID_ADMIN_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "solid-pod-rs admin PSK used by management-api admin-users (devuser)." + }, + "SSO_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (System-One façade). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "TUNNEL_TOKEN": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Cloudflare tunnel credential (cloudflared name)." + }, + "TYPESAFE_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (TypeSafe skill router). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "VAULT_NOSTR_SECRET": { + "exception": "Q8; ROLE candidate for W3b", + "reason": "Signing key of the vault CLI (31402 proposals), forwarded by management-api ontology-apply. Its consumer is the external vault CLI, which has no _FILE input yet; recorded as an exception rather than broken. Move to ROLE when the vault CLI reads a file or signs through the identity port." + }, + "VISIONCLAW_AGENT_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Header key management-api presents to VisionClaw broker-bridge (devuser)." + }, + "WEBHOOK_HMAC_SECRET": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "git-bridge webhook HMAC secret, management-api (devuser)." + }, + "ZAI_ANTHROPIC_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Z.AI, dream-engine and session-summary). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "ZAI_API_KEY": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Provider credential (Z.AI). Agents and their tools are its users; devuser-class by design (design §1 role inventory)." + }, + "_CS_PASSWORD": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Entrypoint-local copy of the code-server password; written to its 0600 devuser config, never exported." + }, + "_PG_PW_ESC": { + "exception": "Q8 (queen 2026-10-03, pending owner): provider and devuser credentials stay in devuser's environment this step", + "reason": "Entrypoint-local escaped copy of RUVECTOR_PG_PASSWORD for a template; never exported." + } + }, + "NON_SECRET": { + "$comment": "Paths, ports, switches, model names, public keys, shell locals. Secret-shaped names must be listed exactly; no pattern may classify one.", + "names": [ + "AB_RC_CHOWN", + "AB_RC_DELIVERED", + "AB_RC_DEVUSER_IDS", + "AB_RC_FAILURES", + "AB_RC_MAX_SECRET_BYTES", + "AB_RC_MAX_SEED_BYTES", + "AB_RC_MIGRATED", + "AB_RC_MIG_FAILURES", + "AB_RC_REFUSED", + "AB_RC_REVERTED", + "AB_RC_ROOT", + "AB_RC_STAT", + "AB_ROOT_STATE_DIR", + "AGENTBOX_ADMIN_PUBKEY", + "AGENTBOX_AGENT", + "AGENTBOX_AGENT_DID", + "AGENTBOX_AGENT_EVENT_AUTH", + "AGENTBOX_AGENT_ID", + "AGENTBOX_AGENT_IDENTITY_DIR", + "AGENTBOX_AGENT_PRIVKEY_HEX_FILE", + "AGENTBOX_AGENT_PUBKEY", + "AGENTBOX_AGENT_REPO_ROOT", + "AGENTBOX_AGENT_TYPE", + "AGENTBOX_ALLOWED_PUBKEYS", + "AGENTBOX_ANTIGRAVITY_MODEL", + "AGENTBOX_AOE_TOKEN_FILE", + "AGENTBOX_APPROVAL_ALLOWLIST", + "AGENTBOX_AUTO_MODE", + "AGENTBOX_BASE_URL", + "AGENTBOX_BOOTSTRAP_STAGE", + "AGENTBOX_BRIDGE_BIN", + "AGENTBOX_BRIDGE_RECIPIENT_PUBKEY", + "AGENTBOX_BRIDGE_SK_FILE", + "AGENTBOX_CODEX_MODEL", + "AGENTBOX_CONFIG", + "AGENTBOX_DID", + "AGENTBOX_DIR", + "AGENTBOX_EGRESS", + "AGENTBOX_EGRESS_REDACTION", + "AGENTBOX_ELEVATION_DOMAIN", + "AGENTBOX_EMAIL_GATEWAY_URL", + "AGENTBOX_ENV_FILE", + "AGENTBOX_EVENT_ARCHIVE", + "AGENTBOX_EVENT_ARCHIVE_DIR", + "AGENTBOX_FEDERATION_MODE", + "AGENTBOX_GATEWAY_IDENTITY", + "AGENTBOX_GATEWAY_KEY_TAG", + "AGENTBOX_GATEWAY_REARM_MS", + "AGENTBOX_GATEWAY_REPLY_TO", + "AGENTBOX_HOOK_SHIM", + "AGENTBOX_HOST_WS_URL", + "AGENTBOX_IDENTITY_ACL", + "AGENTBOX_IDENTITY_KEY_DIR", + "AGENTBOX_IDENTITY_RECEIPT_DIR", + "AGENTBOX_IDENTITY_ROOT", + "AGENTBOX_IDENTITY_SOCK", + "AGENTBOX_IDENTITY_SOCK_GID", + "AGENTBOX_IMAGE_HASH", + "AGENTBOX_IMAGE_REF", + "AGENTBOX_INBOUND_EVENTS_LOG", + "AGENTBOX_INTENT_ACTOR", + "AGENTBOX_INTERACTION_PLANE", + "AGENTBOX_INTERACTION_PLANE_PORT", + "AGENTBOX_KERNEL_WHEELHOUSE", + "AGENTBOX_LIVE_MIRROR_EVENTS", + "AGENTBOX_LOG_LEVEL", + "AGENTBOX_MANIFEST_CHECKSUM", + "AGENTBOX_MANIFEST_PATH", + "AGENTBOX_MCP_HUB", + "AGENTBOX_MCP_HUB_BIND", + "AGENTBOX_MCP_HUB_SERVERS", + "AGENTBOX_MCP_HUB_URL", + "AGENTBOX_METRICS_HOST", + "AGENTBOX_METRICS_PORT", + "AGENTBOX_MIRROR_CHILD", + "AGENTBOX_MIRROR_DRY_RUN", + "AGENTBOX_MIRROR_KEY_TAG", + "AGENTBOX_MIRROR_RECIPIENT_PUBKEY", + "AGENTBOX_NODE_LABEL", + "AGENTBOX_NOSTR_GATEWAY", + "AGENTBOX_NPUB", + "AGENTBOX_NSEC_FILE", + "AGENTBOX_OPERATOR_DID", + "AGENTBOX_OTLP_ENDPOINT", + "AGENTBOX_PERMISSIONS_PROJECT", + "AGENTBOX_PG_NODE_PATH", + "AGENTBOX_POD_BRIDGE_BIN", + "AGENTBOX_POD_INBOX_WRITER", + "AGENTBOX_POD_ROOT", + "AGENTBOX_PRIVKEY_HEX_FILE", + "AGENTBOX_PROFILE", + "AGENTBOX_PROJECT_TRACKING_PUBLISH", + "AGENTBOX_PUBKEY", + "AGENTBOX_RELAY_ALLOWED_PUBKEYS", + "AGENTBOX_RELAY_BIND", + "AGENTBOX_RELAY_ENABLED", + "AGENTBOX_RELAY_FANOUT", + "AGENTBOX_RELAY_IMPL", + "AGENTBOX_RELAY_POD_BRIDGE", + "AGENTBOX_RELAY_POLICY", + "AGENTBOX_ROLE_ISOLATION", + "AGENTBOX_RUNTIME_ENV", + "AGENTBOX_RUN_ROOT", + "AGENTBOX_SECRETS_DIR", + "AGENTBOX_SECRETS_ROOT", + "AGENTBOX_SESSION_DIGEST", + "AGENTBOX_SKILL_ROUTE_API", + "AGENTBOX_SKILL_ROUTE_MODEL", + "AGENTBOX_SOVEREIGN_MESH_ENABLED", + "AGENTBOX_STACK", + "AGENTBOX_STATE", + "AGENTBOX_STATE_DIR", + "AGENTBOX_STRICT_IMMUTABLE", + "AGENTBOX_TAB0_BRIDGE_SUPERVISED", + "AGENTBOX_TAB0_BRIDGE_URL", + "AGENTBOX_TRUST_SEED", + "AGENTBOX_URN", + "AGENTBOX_USER_ID", + "AGENTBOX_VAULT_ENABLED", + "AGENTBOX_VAULT_LEGACY_PATHS", + "AGENTBOX_VERSION", + "AGENTBOX_X_ONLY_PUBKEY_HEX", + "AGENTBOX_ZAI_MODEL", + "AGENTS_TREE", + "AGENT_PRIVKEY_HEX_FILE", + "ALL", + "ANALYTICS_MULTIPLIER", + "ANDROID_IMAGE", + "AOE_TOKEN_FILE", + "AOE_UPSTREAM", + "ASR_CUDA_VISIBLE_DEVICES", + "ASR_LANGUAGE", + "ASR_LOOKAHEAD_TOKENS", + "ASR_MODEL", + "AUDIT_WRITE", + "BASE_COST_SATS", + "BASH_COMMAND", + "BC20_URN_MAPPING_PATH", + "BDF", + "BLENDER_MCP_BIND", + "BLENDER_MCP_PORT", + "BRIDGE_TOKEN_FILE", + "BUS_DEC", + "BUS_HEX", + "CARGO_CONFIG", + "CARGO_HOME", + "CHOWN", + "CLAUDE_BIN", + "CLAUDE_CONFIG_DIR", + "CLAUDE_FLOW_PLUGIN_DIR", + "CODEX_CFG", + "CODEX_HOME", + "CODEX_MP_DIR", + "CODEX_MP_SRC", + "COLLOQUY_NAMESPACE", + "COLLOQUY_PRINCIPAL", + "COLLOQUY_STORE_PATH", + "COLLOQUY_TIER", + "COMFYUI_API_ENDPOINT", + "COMFYUI_GPU_DEVICE", + "COMFYUI_LOCAL_ENDPOINT", + "COMFYUI_MODELS_CHECKPOINTS", + "COMFYUI_MODELS_CONTROLNET", + "COMFYUI_MODELS_LORAS", + "COMFYUI_MODELS_UPSCALE", + "COMFYUI_MODELS_VAE", + "COMFYUI_NETWORK", + "COMFYUI_OUTPUTS", + "COMFYUI_URL", + "CONCAT_LOG", + "CONCAT_PROJECTS", + "CONCAT_REV", + "CONCIERGE_IGNORE_PUBKEYS", + "CONCIERGE_PRIVKEY_HEX_FILE", + "CONNECTED_NODE_HOME", + "CONNECTED_NODE_SSH", + "CONSUMER_ENABLED", + "CORS_ALLOWED_ORIGINS", + "COST_GATE_FAIL_CLOSED", + "CPATH", + "CUDA_PATH", + "CUDA_VISIBLE_DEVICES", + "DAC_OVERRIDE", + "DEEPSEEK_BASE_URL", + "DEV_DEC", + "DEV_HEX", + "DISPLAY", + "DREAM_CMD_DST", + "DREAM_CMD_SHA", + "DREAM_CMD_SRC", + "DREAM_DIGEST_CHANNEL", + "DREAM_DIGEST_RELAY", + "DREAM_DIGEST_SECTION", + "DREAM_GOVERNANCE", + "DREAM_INBOX_HOOK", + "DREAM_PER_SAT", + "DREAM_RELAY", + "EMBEDDING_MODEL", + "EMPTY", + "ENABLE_ACI_SHELL", + "ENABLE_AGENTIC_QE", + "ENABLE_CLAUDE_FLOW", + "ENABLE_CODEX", + "ENABLE_CODE_INTERPRETER", + "ENABLE_COMFYUI_BUILTIN", + "ENABLE_DESKTOP", + "ENABLE_EMAIL_SEARCH", + "ENABLE_JUPYTER", + "ENABLE_RUFLO", + "ENABLE_RUVNET_BRAIN", + "ENABLE_XINFERENCE", + "ENCRYPTION_ENABLED", + "EXP_B8_NOW", + "EXP_B8_STATE_DIR", + "FISH_CONF_D", + "FN_DEC", + "FN_HEX", + "FORUM_AUTH_API", + "FORUM_MAX_PER_NIGHT", + "FORUM_RELAY_URL", + "FORUM_SECTION_PREFIX", + "FORUM_THREAD_PREFIX", + "FOWNER", + "GEMMA_BASE_URL", + "GEMMA_MODEL", + "GIT_BRIDGE_WORKSPACE_ROOT", + "GIT_CONFIG_GLOBAL", + "GIT_DEFAULT_BRANCH", + "GIT_HTTP_BACKEND", + "GIT_MAX_PUSH_MB", + "GIT_POD_ENABLED", + "GIT_READ_PUBLIC", + "GW_DEBUG", + "HF_HOME", + "HOLD_BUFFER_RATIO", + "HOME", + "IMAGE_GEN_MULTIPLIER", + "INFERENCE_MULTIPLIER", + "INSTALLED_JSON", + "JSON", + "JUNKIEJARVIS_CLARIFY_BEFORE_ACTING", + "JUNKIEJARVIS_IGNORE_PUBKEYS", + "JUNKIEJARVIS_LLM_BASE", + "JUNKIEJARVIS_LLM_MAX_TOKENS", + "JUNKIEJARVIS_MAX_REPLY", + "JUNKIEJARVIS_MODEL", + "JUNKIEJARVIS_NOSTR_RELAYS", + "JUNKIEJARVIS_PRIVKEY_HEX_FILE", + "JUNKIEJARVIS_PROFILE_DELAY_MS", + "KILL", + "KNOWLEDGE_GIT_CLONE", + "LANG", + "LAYA_BIND_HOST", + "LAYA_DEFAULT_MODEL", + "LAYA_DEVICE", + "LAYA_HEAD_MAX_LEN", + "LAYA_MAX_LEN", + "LAYA_MODELS", + "LAYA_MODEL_DIR", + "LAYA_PORT", + "LAYA_REQUEST_LOG", + "LD_LIBRARY_PATH", + "LIBRARY_PATH", + "LINENO", + "LINKED_OBJECT_PROXY_ALLOWED_ORIGINS", + "LOCAL_LLM_HOST", + "LOCAL_LLM_PORT", + "LOG_LEVEL", + "LOOM_BASE_URL", + "LOOM_FACADE_URL", + "LOOM_MODEL", + "LOOM_RAW_BASE_URL", + "MANAGEMENT_API_AUTH_MODE", + "MANAGEMENT_API_HOST", + "MANAGEMENT_API_PORT", + "MANAGEMENT_API_URL", + "MAX_SSE_CONNECTIONS", + "MCP_TCP_HOST", + "MCP_TCP_PORT", + "MEMORY_ADMIN_ACCESS_MODE", + "MGMT_KEY_FILE", + "MISSING", + "MUSL_CC", + "NET_ADMIN", + "NIP98_PROXY_ALLOWED_PUBKEYS", + "NIP98_PROXY_ALLOW_BEARER_FILE", + "NIP98_PROXY_BEARER_EXPIRES_AT", + "NIP98_PROXY_BEARER_PUBKEY", + "NIP98_PROXY_BEARER_SCOPE", + "NIP98_PROXY_CONFIG_FILE", + "NIP98_PROXY_HOST", + "NIP98_PROXY_MAX_BODY", + "NIP98_PROXY_MGMT_UPSTREAM", + "NIP98_PROXY_PORT", + "NIP98_PROXY_ROUTES", + "NIP98_PROXY_SESSION_SECRET_FILE", + "NIP98_PROXY_SESSION_TTL", + "NIX_XORG_MOD", + "NODE_ENV", + "NODE_PATH", + "NOSTR_BRIDGE_PATH", + "NOSTR_GATEWAY_EFFORT", + "NOSTR_GATEWAY_MODEL", + "NOSTR_KEY_FILE", + "NOSTR_MIRROR_RELAY", + "NOSTR_RELAYS", + "NVIDIA_DRIVER_CAPABILITIES", + "NVIDIA_VISIBLE_DEVICES", + "OLLAMA_BASE_URL", + "OLLAMA_MODEL", + "ONTOLOGY_ALIASES", + "ONTOLOGY_CONDENSE_ENDPOINT", + "ONTOLOGY_PAGES_DIR", + "ONTO_BUILDER", + "ONTO_PAGES", + "OPENAI_BASE_URL", + "OPENJEV_BIND_HOST", + "OPENJEV_DEFAULT_MODEL", + "OPENJEV_DEVICE", + "OPENJEV_DTYPE", + "OPENJEV_GPU", + "OPENJEV_HYP_CHUNK", + "OPENJEV_MAX_LEN", + "OPENJEV_MODELS", + "OPENJEV_MODEL_DIR", + "OPENJEV_NOUL_MODE", + "OPENJEV_PORT", + "OPENJEV_REQUEST_LOG", + "OPENMED_ARTIFACT_LOCK_SHA256", + "OPENMED_GOVERNANCE_ACKNOWLEDGED", + "OPENMED_HMAC_KEY_TAG", + "OPENMED_LICENSE_ACKNOWLEDGED", + "OPENMED_MODEL_ARTIFACT", + "OPENMED_MODEL_DIR", + "OPENMED_ONNX_RUNTIME_PRESENT", + "OPENMED_PORT", + "OPENMED_SERVER_DIR", + "OPENSSL_DIR", + "OPENSSL_INCLUDE_DIR", + "OPENSSL_LIB_DIR", + "OPERATOR_NOSTR_PRIVKEY_FILE", + "OPERATOR_NOSTR_PUBKEY", + "OPF_ENDPOINT", + "OPF_MODE", + "PATH", + "PCI_BUS", + "PER_USER_AGENTS_ENABLED", + "PER_USER_POD_BASE", + "PINNED_SESSION_SECRET", + "PNPM_CONFIG_STORE_DIR", + "POD_BASE", + "POSTGRES_DB", + "POSTGRES_USER", + "PROCESS_MANAGER_LOGS_DIR", + "PROJECT_DIR", + "PROJECT_DIR_2", + "PROJECT_DIR_3", + "PROJECT_DIR_4", + "PROJECT_DIR_5", + "QGIS_MCP_BIND", + "QGIS_MCP_PORT", + "RAW_BUS", + "RES", + "RUFLO_DAEMON_AI_WORKERS", + "RUNTIME_ENV_DURABLE", + "RUNTIME_ENV_FILE", + "RUSTUP_HOME", + "RUST_LOG", + "RUVECTOR_DATA_DIR", + "RUVECTOR_PORT", + "RUVECTOR_USE_EXTERNAL", + "RUVNET_BRAIN_AUTO_INGEST", + "RUVNET_BRAIN_GROUNDING_HOOK", + "RUVNET_BRAIN_NAMESPACE", + "RUVNET_BRAIN_STAGING", + "SESSION_ID", + "SET", + "SETGID", + "SETUID", + "SHARED_PROJECTS_ROOT", + "SHELL", + "SIDECAR_PORT", + "SIDECAR_TRANSPORT", + "SIDESTR_AGENT_BIN", + "SIDESTR_CHAIN", + "SIDESTR_CHAIN_EVENT", + "SIDESTR_DOC", + "SIDESTR_INTERVAL", + "SIDESTR_PAYMENTS_FILE", + "SIDESTR_PORT", + "SIDESTR_STATUS_URL", + "SIDESTR_TX_INTERVAL", + "SKILLS_TREE", + "SKILL_CREATOR_DIR", + "SOLID_POD_BASE_URL", + "SOLID_POD_PORT", + "SOLID_POD_PUBLIC_URL", + "SOLID_POD_ROOT", + "SOLID_STORAGE_ROOT", + "SSO_BIND", + "SSO_CACHE_PATH", + "SSO_EMBEDDINGS_MODEL", + "SSO_EMBEDDINGS_URL", + "SSO_ENGINE_URL", + "SSO_LOG", + "SSO_MODEL", + "SSO_SHORTLIST_K", + "SSO_WINDOW_K", + "STAGE_B_MODE", + "SYSTEMONE_GPU", + "TAILSCALE_AUTHKEY_FILE", + "TERM", + "TMPDIR", + "TREAT_AS_SECURE", + "USER", + "USER_PUBKEY", + "VAR", + "VARS", + "VAULT_BIN", + "VAULT_FORMAT", + "VAULT_PAGES", + "VAULT_RELAY_URL", + "VAULT_REPO", + "VAULT_ROOT", + "VAULT_TRANSCRIPTS", + "VAULT_TUI", + "VAULT_WORKING_PAGES", + "VAULT_WORKING_ROOT", + "VISIONCLAW_API_URL", + "VISIONCLAW_MEMORY_FLASH", + "VISIONCLAW_MEMORY_FLASH_TIMEOUT_MS", + "VISIONCLAW_MEMORY_FLASH_URL", + "VISIONCLAW_NETWORK", + "VISIONCLAW_WS_URL", + "VITE_POD_API_URL", + "VOICE_HOST_ROOT", + "WHEELHOUSE", + "WLR_BACKENDS", + "WORKSPACE", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "XINFERENCE_EMBED_WAIT_SECS", + "XINFERENCE_ENDPOINT", + "XINFERENCE_READY", + "XINFERENCE_WAIT_SECS", + "XORG_BIN", + "XORG_CONF", + "XORG_RESOLUTION", + "XR_BACKEND_WS", + "XR_GODOT_SCENE", + "XR_INPUT_DRIVER", + "XR_PROJECT_ROOT", + "XR_RUNTIME_HEADLESS", + "XR_RUNTIME_JSON", + "ZAI_CONTAINER_URL", + "ZAI_ENABLED", + "ZAI_MODEL", + "ZAI_URL", + "ZONE_CONFIG", + "ZONE_KEYS_FILE", + "_AB_ROLE_CUSTODY_LIB", + "_AB_ROLE_ENV_VARS", + "_AB_SECRETS_MOUNT_STATE", + "_AB_SUPERVISORD_CONF", + "_AGENT_ID_DID", + "_AGENT_ID_EXPECTED", + "_AGENT_ID_NODE", + "_AGENT_ID_OUT", + "_AGENT_ID_PK", + "_AGENT_ID_SCRIPT", + "_AOE_CFG_DIR", + "_AOE_MODE", + "_AOE_OWN", + "_AOE_SEED", + "_AOE_URL_FILE", + "_BROWSER_MCP_URL", + "_CF_CONFIG_DIR", + "_CF_TEMPLATE", + "_CLAUDE_GLOBAL", + "_CLAUDE_SETTINGS", + "_CODEX_AGENTS", + "_CODEX_CONFIG", + "_CODE_SERVER_ON", + "_COLLOQUY_BIN", + "_COLLOQUY_GATE", + "_COLLOQUY_NS", + "_COLLOQUY_ON", + "_COLLOQUY_OP", + "_COLLOQUY_PRINCIPAL", + "_COLLOQUY_REFLECT", + "_COLLOQUY_STORE", + "_COLLOQUY_TIER", + "_CONSULTANTS_ON", + "_CQ_HOOK", + "_CQ_RECORDED", + "_CS_AUTH_FILE", + "_CS_DATA_DIR", + "_CS_XDG_CFG_DIR", + "_DREAM_INBOX_HOOK", + "_EMAIL_GW_URL", + "_FLEET_HOOK", + "_GIT_SAFE", + "_HARNESS_BRIDGE", + "_HARNESS_ON", + "_HARNESS_TEMPLATE_DIR", + "_HOOK_REGISTRY", + "_INSTR_GLOBAL", + "_INSTR_LAYERS", + "_IP_ENABLED", + "_IP_PORT", + "_IP_PROXY_PORT", + "_JC_ARGS", + "_JC_BAKED", + "_JC_BIN", + "_JC_CACHE", + "_JC_CFG_HAVE", + "_JC_CFG_STAMP", + "_JC_CFG_WANT", + "_JC_DECLARED", + "_JC_HAVE", + "_JC_MARKET", + "_JC_ON", + "_JC_PLUGIN", + "_JC_VER", + "_JUPYTER_DATA_DIR", + "_JUPYTER_TOKEN_FILE", + "_MCP_HUB_CONFIG", + "_MCP_HUB_STATE", + "_MCP_JSON", + "_MCP_PROJECTOR", + "_MCP_REGISTRY", + "_MCP_SERVERS_NODE_PATH", + "_MIRROR_HOOK", + "_ML_AGG_MIN", + "_ML_AGG_SWEEP", + "_ML_AGG_SWEEP_INT", + "_ML_ATTENTION_RERANK", + "_ML_ENABLED", + "_ML_FEED_RETRIEVAL", + "_ML_FEED_ROUTING", + "_ML_HALFLIFE", + "_ML_PARAM_TUNING", + "_ML_PATTERN_DISTILL", + "_ML_RECORD_TRAJ", + "_ML_RETAINED_CORPUS", + "_ML_RETAINED_MAX_AGE", + "_ML_SONA_APPLY", + "_ML_SONA_LEARN", + "_MRN_BAR", + "_MRN_CEIL", + "_MRN_DIR", + "_MRN_ENABLED", + "_MRN_EXPORTS", + "_MRN_FALLBACK", + "_MRN_PROVIDER", + "_MRN_TIER", + "_MRN_TRAJ", + "_MR_AQE_PROVIDER", + "_MR_ENABLED", + "_MR_PROVIDER_ARG", + "_NIP98_CFG", + "_ONTOLOGY_HOOK", + "_ONTOLOGY_MONITOR_MODE", + "_ONTOLOGY_MONITOR_ON", + "_PERM_DONE", + "_PERPLEXITY_ENTRY", + "_PG_NODE_PATH", + "_PG_PREFIX", + "_PLUGIN_DIR", + "_PLUGIN_LIST", + "_RB_GROUND_HOOK", + "_RB_INGEST", + "_RB_MCP_DIR", + "_RB_NS", + "_RB_STAGING", + "_RECONCILE_AGENTS", + "_RECONCILE_COMMANDS", + "_RECONCILE_SKILLS", + "_RL_HOOK", + "_ROLE_ISO_EXPORTS", + "_RUFLO_PLUGINS_CACHE", + "_RUFLO_PLUGINS_REPO", + "_RUVECTOR_MCP", + "_RV_EPISODIC_TTL_SWEEP", + "_RV_HEALTH_TOOL", + "_RV_HYBRID_SEARCH", + "_RV_MEMORY_ORIENT", + "_RV_METADATA_GIN", + "_RV_ORCH_PROXY", + "_RV_ORCH_TOOLS", + "_RV_TYPED_METADATA", + "_SINK_HOOK", + "_SKILL_ROOTS_PROJECTOR", + "_SOVEREIGN_ID_FILE", + "_SR_CASCADE", + "_SR_CASCADE_CUTOFF", + "_SR_CASCADE_EXPORTS", + "_SR_HOOK", + "_SR_HOOK_FILE", + "_SR_LABEL_EMBED_URL", + "_SR_LABEL_LOG", + "_SR_MIN_CHARS", + "_SR_MODEL", + "_SR_ROUTER", + "_SR_TIMEOUT", + "_SSO_API", + "_SSO_EXPORTS", + "_SSO_MODEL", + "_SSO_PLUGIN_CONFIG", + "_TRAJ_HOOK", + "_TRUST_HOOK", + "_URL_GO", + "_URL_MODE", + "_URL_OWN", + "_USER_CLAUDE_JSON", + "_VAULT_BIN", + "_VAULT_VER", + "_WSR", + "_WS_AGENTS", + "__GLX_VENDOR_LIBRARY_NAME" + ], + "patterns": [] + } + }, + "role_files": { + "$comment": "ROLE secrets that already reach their reader by FILE, with only a path in the environment. Nothing to scrub; W1 moves the files to their role's ownership.", + "sidestr signer key": "SIDESTR_KEY (path; default /var/lib/agentbox/secrets/sidestr-.key, config/sidechain/run-producer.sh)", + "sidestr treasury key": "[sidechain.*].faucet_key_file in agentbox.toml (path; config/sidechain/run-faucet.sh)", + "forum zone keys": "ZONE_KEYS_FILE (path; default $WORKSPACE/.agentbox/zone-keys.json)", + "pod signer (sovereign identity JSON)": "AGENTBOX_IDENTITY_ROOT + AGENTBOX_AGENT_ID (paths; management-api/lib/agent-identity.js loadSovereignSigner)", + "per-agent DID key files": "AGENTBOX_AGENT_IDENTITY_DIR (path; agent-did-.key)", + "pay402 spend keys": "AGENTBOX_AGENT_IDENTITY_DIR (path; agent-spend--.key); Q6 defers their role", + "relay daemon key": "AGENTBOX_BRIDGE_SK_FILE (path; /run/secrets/nostr.key)" + }, + "w1_plan": { + "$comment": "Parity with W1's delivery table (config/role-accounts.json, ADR-2122). Every from_env there is ROLE here with the same role (checked). ROLE vars W1's plan does not carry are delivered only by the W2 capture and listed here, so drift either way fails the inventory.", + "file": "config/role-accounts.json", + "w2_only": { + "OPERATOR_NOSTR_PRIVKEY": "operator key fallback; not in W1's ab-identity rows", + "AGENTBOX_AGENT_PRIVKEY_HEX": "per-agent DID override; not in W1's ab-identity rows", + "AGENT_PRIVKEY_HEX": "per-user agent key; not in W1's ab-identity rows", + "TAILSCALE_AUTHKEY": "role root (tailscale-up); W1's plan carries ab-* roles only" + } + } +} diff --git a/config/custody/g5-key-split.json b/config/custody/g5-key-split.json new file mode 100644 index 000000000..f332449ab --- /dev/null +++ b/config/custody/g5-key-split.json @@ -0,0 +1,33 @@ +{ + "$comment": [ + "G-5 key split (TODO row G-5; custody design §11; docs/estate-closeout/g5-key-split-handoff.md).", + "PUBLIC KEYS ONLY. A private key, nsec or seed never goes in this file or anywhere in this repo.", + "Each replacement key is minted inside its eventual holder, never in agentbox (D3, 2026-10-03).", + "pubkey stays null until the owner supplies the 64-hex x-only public key. When it is set, add the", + "same pubkey to every path in agentbox_verifiers. tests/sovereign/g5-key-split.node-test.js then", + "proves the real verifier admits it alongside house_key. house_key stays admitted until its role is", + "listed in house_key_withdrawn_from (migration step E, after owner rebuild 1 and the signer switch).", + "K_admin (forum house admin) is deliberately absent: the operator creates it in a NIP-07 extension", + "and its only verifier is the forum deploy, not agentbox (D2, 2026-10-03)." + ], + "house_key": "11ed64225dd5e2c5e18f61ad43d5ad9272d08739d3a20dd25886197b0738663c", + "replacements": [ + { + "role": "k_browser", + "replaces_use": "U8: browser automation identity", + "minted_in": "a NIP-07 signer inside browsercontainer's Chromium profile (the owner creates it there)", + "pubkey": null, + "agentbox_verifiers": ["interaction_plane.proxy.allowed_pubkeys"], + "external_verifiers": [] + }, + { + "role": "k_broker", + "replaces_use": "U7: VisionClaw governance signer (kinds 31400/31402)", + "minted_in": "inside visionclaw_container (nostr-tools generateSecretKey), written by the owner from tab 6 over VISIONCLAW_NOSTR_PRIVKEY in VisionClaw .env", + "pubkey": null, + "agentbox_verifiers": [], + "external_verifiers": ["dreamlab forum relay whitelist (nostr-rust-forum deploy)"] + } + ], + "house_key_withdrawn_from": [] +} diff --git a/config/custody/identity-port-acl.json b/config/custody/identity-port-acl.json new file mode 100644 index 000000000..88f357f7d --- /dev/null +++ b/config/custody/identity-port-acl.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "_comment": [ + "Identity-port ACL (custody X-1 step 1, W3; ADR-2122; closed operation list per ADR-2101 :110-111).", + "Loaded once by `nostr-pod-bridge serve-identity` (AGENTBOX_IDENTITY_ACL). Authorisation is the caller's", + "uid from SO_PEERCRED, never the socket mode. Callers are keyed by numeric uid; `name` is for receipts only.", + "uids follow design §2.2 (960 identity, 961 gateway, 962 ingress, 963 spend); W1's role registry is authoritative.", + "Key files live under AGENTBOX_IDENTITY_KEY_DIR (default /run/secrets/ab-identity, W1's delivery dir; file names are the", + "variable names, as W2's role_secret readers expect). AGENTBOX_PRIVKEY_HEX must hold the", + "operator hex exactly as AGENTBOX_PRIVKEY_HEX supplies it (not the even-y normalised scalar): the mirror child", + "is derived from it (src/mirror_key.rs), and an odd-y house key would otherwise hand the phone a different key.", + "`manifest:a.b.c` resolves from /etc/agentbox.toml at start; an unresolved reference grants nothing.", + "Kinds 27235, 22242, 31400-31405 and 38414 cannot be granted (the loader refuses the file).", + "mirror_key `secret: true` returns the live-mirror CHILD secret (the key the phone holds) because", + "config/hooks/nostr-live-mirror.cjs:487 signs the self-DM wrap with it; the sovereign secret never crosses." + ], + "keys": { + "core": { + "file": "AGENTBOX_PRIVKEY_HEX", + "required": true, + "mirror_root": true, + "nip98_url_prefixes": ["manifest:integrations.solid_pod_rs.base_url"], + "relays": [ + "ws://127.0.0.1:7777", + "wss://dreamlab-nostr-relay.solitary-paper-764d.workers.dev" + ] + }, + "junkiejarvis": { + "file": "JUNKIEJARVIS_PRIVKEY_HEX", + "required": false + } + }, + "callers": { + "1000": { + "name": "devuser", + "ops": { + "pubkey": { "keys": ["core", "junkiejarvis"] }, + "nip98": { "keys": ["core"] }, + "sign_event": { "keys": ["core"], "kinds": [38410, 38411, 38412, 38413, 38415, 30840, 30841] }, + "forum_event": { "keys": ["junkiejarvis"], "kinds": [1, 42, 31923] }, + "nip42_auth": { "keys": ["core"] }, + "mirror_key": { "secret": true } + } + }, + "961": { + "name": "ab-gateway", + "ops": { + "pubkey": { "keys": ["core"] }, + "nip42_auth": { "keys": ["core"] }, + "mirror_key": {} + } + } + } +} diff --git a/config/docker-read-proxy.cjs b/config/docker-read-proxy.cjs new file mode 100644 index 000000000..62976b7fd --- /dev/null +++ b/config/docker-read-proxy.cjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +'use strict'; +// docker-read-proxy — GET-only window onto the host Docker socket for devuser +// under [security].role_isolation (custody X-1 step 1, W0; owner question Q1). +// +// The host Docker daemon is root on the host. With role isolation on, the +// entrypoint stops widening /var/run/docker.sock, and devuser's docker CLI +// points here instead (DOCKER_HOST=unix:///run/docker-ro.sock, set by the +// Phase-8 runtime env). Monitoring keeps working: docker ps, logs, inspect, +// version, info. Everything else gets 403: exec, run, create, start, kill, +// rm, cp in or out (archive), export, attach, images, volumes, build. +// `docker exec` moves to the host shell. +// +// Shape: +// - Runs only when AGENTBOX_ROLE_ISOLATION=1; otherwise exits 0 at once. +// - Started as root by supervisord, binds the listen socket with umask 0111 +// (mode 0666 at bind time, no chmod, so nothing can be swapped in between), +// then drops to uid/gid 65534 keeping exactly one supplementary group: the +// upstream socket's group. It refuses to join group 0. +// - Strict allowlist on method and raw path; no percent-encoding, dot +// segments, double slashes or absolute-form targets; no Upgrade. +// - Request bodies are never forwarded; hop-by-hop headers are dropped. +// +// No dependencies beyond node:http and node:fs. Test: +// tests/runtime-contract/RC-X1-05.node-test.cjs. +// TODO(custody W1): register this program in lib/role-accounts.nix with its +// own account instead of uid 65534 + the socket's group. + +const fs = require('node:fs'); +const http = require('node:http'); + +const DEFAULT_UPSTREAM = '/var/run/docker.sock'; +const DEFAULT_LISTEN = '/run/docker-ro.sock'; +const NOBODY = 65534; +const MAX_URL = 4096; + +// Optional /vMAJOR.MINOR prefix, then exactly one allowed route. Container ids +// and names: Docker's own charset, first character alphanumeric. +const ROUTE = /^(?:\/v1\.\d{1,3})?\/(?:_ping|version|info|containers\/json|containers\/[A-Za-z0-9][A-Za-z0-9_.-]{0,254}\/(?:json|logs))$/; +const QUERY = /^[A-Za-z0-9%&=_.,:+~*-]*$/; +const HOP_BY_HOP = new Set([ + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', + 'content-length', 'expect', +]); + +/** + * Decide whether a request may pass to the Docker daemon. + * @param {string} method HTTP method as received. + * @param {string} rawUrl Request target exactly as received (path + query). + * @returns {boolean} true only for GET/HEAD on the read-only allowlist. + */ +function isAllowed(method, rawUrl) { + if (method !== 'GET' && method !== 'HEAD') return false; + if (typeof rawUrl !== 'string' || rawUrl.length === 0 || rawUrl.length > MAX_URL) return false; + if (rawUrl.includes('#')) return false; + const q = rawUrl.indexOf('?'); + const pathPart = q === -1 ? rawUrl : rawUrl.slice(0, q); + const query = q === -1 ? '' : rawUrl.slice(q + 1); + if (!ROUTE.test(pathPart)) return false; + if (!QUERY.test(query)) return false; + return true; +} + +function cleanHeaders(headers) { + const out = {}; + for (const [k, v] of Object.entries(headers)) { + if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v; + } + return out; +} + +function deny(res, method, rawUrl, why) { + const pathOnly = String(rawUrl).split('?')[0].slice(0, 200).replace(/[^\x20-\x7e]/g, '?'); + process.stderr.write(`[docker-read-proxy] deny ${String(method).slice(0, 10)} ${pathOnly} (${why})\n`); + const body = JSON.stringify({ + message: `docker-read-proxy: ${method} ${pathOnly} is refused; this socket is read-only ` + + '(role_isolation). ps, logs, inspect, version and info work here; run exec/run/cp from the host shell.', + }); + res.writeHead(403, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), Connection: 'close' }); + res.end(method === 'HEAD' ? undefined : body); +} + +/** + * Build the proxy server (not yet listening). + * @param {{upstream: string}} opts Path of the real Docker socket. + * @returns {http.Server} + */ +function createProxy({ upstream }) { + const server = http.createServer((req, res) => { + req.resume(); // never forward a body + if (req.headers.upgrade || /\bupgrade\b/i.test(String(req.headers.connection || ''))) { + return deny(res, req.method, req.url, 'upgrade'); + } + if (!isAllowed(req.method, req.url)) return deny(res, req.method, req.url, 'not on allowlist'); + + const up = http.request({ + socketPath: upstream, + method: req.method, + path: req.url, + headers: { ...cleanHeaders(req.headers), host: 'docker' }, + agent: false, + }, (upRes) => { + res.writeHead(upRes.statusCode || 502, cleanHeaders(upRes.headers)); + upRes.pipe(res); + }); + up.on('error', (err) => { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: `docker-read-proxy: upstream unavailable (${err.code || 'error'})` })); + } else { + res.destroy(); + } + }); + res.on('close', () => up.destroy()); + up.end(); + }); + // A protocol upgrade (attach/ws, exec hijack) never reaches the handler above: + // with an 'upgrade' listener Node hands us the raw socket, which we refuse. + server.on('upgrade', (req, socket) => { + process.stderr.write('[docker-read-proxy] deny upgrade\n'); + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + }); + server.maxConnections = 64; + server.headersTimeout = 15000; + server.requestTimeout = 0; // `docker logs -f` streams indefinitely + return server; +} + +function dropPrivileges(socketGid) { + if (socketGid === 0) { + throw new Error('upstream socket group is 0; refusing to join group root (fix the host socket group)'); + } + process.setgroups([socketGid]); + process.setgid(NOBODY); + process.setuid(NOBODY); + if (process.getuid() === 0 || process.geteuid() === 0 || process.getgid() === 0) { + throw new Error('privilege drop did not take'); + } +} + +function main() { + const env = process.env; + if (env.AGENTBOX_ROLE_ISOLATION !== '1') { + process.stdout.write('[docker-read-proxy] role_isolation is off; devuser keeps the raw socket. Exiting.\n'); + process.exit(0); + } + const upstream = env.DOCKER_READ_PROXY_UPSTREAM || DEFAULT_UPSTREAM; + const listen = env.DOCKER_READ_PROXY_LISTEN || DEFAULT_LISTEN; + let st; + try { + st = fs.statSync(upstream); + } catch (e) { + process.stderr.write(`[docker-read-proxy] FATAL: ${upstream}: ${e.code}\n`); + process.exit(1); + } + if (!st.isSocket()) { + process.stderr.write(`[docker-read-proxy] FATAL: ${upstream} is not a socket\n`); + process.exit(1); + } + try { + fs.unlinkSync(listen); // unlink never follows a symlink + } catch (e) { + if (e.code !== 'ENOENT') { + process.stderr.write(`[docker-read-proxy] FATAL: cannot clear ${listen}: ${e.code}\n`); + process.exit(1); + } + } + const server = createProxy({ upstream }); + const prevMask = process.umask(0o111); + server.on('error', (e) => { + process.stderr.write(`[docker-read-proxy] FATAL: ${e.message}\n`); + process.exit(1); + }); + server.listen(listen, () => { + process.umask(prevMask); + try { + if (process.getuid() === 0) dropPrivileges(st.gid); + } catch (e) { + process.stderr.write(`[docker-read-proxy] FATAL: ${e.message}\n`); + process.exit(1); + } + process.stdout.write(`[docker-read-proxy] ${listen} → ${upstream} (GET-only; uid ${process.getuid()}, groups ${process.getgroups().join(',')})\n`); + }); + const stop = () => server.close(() => process.exit(0)); + process.on('SIGTERM', stop); + process.on('SIGINT', stop); +} + +module.exports = { isAllowed, createProxy, ROUTE }; + +if (require.main === module) main(); diff --git a/config/egress-policy.json b/config/egress-policy.json index 630f474f9..f8713259f 100644 --- a/config/egress-policy.json +++ b/config/egress-policy.json @@ -44,5 +44,497 @@ "accepted": "The peer positively acknowledged (relay OK / provider 2xx).", "failed": "The attempt was made and the transport or peer rejected or errored." }, - "notes": "Exit code zero from either path means the calling session was not blocked. It is NOT proof of delivery and NOT proof the path was disabled — read the outcome, not the exit code." + "notes": "Exit code zero from either path means the calling session was not blocked. It is NOT proof of delivery and NOT proof the path was disabled — read the outcome, not the exit code.", + "register": { + "title": "Prompt egress register", + "todo": "N-7", + "verified_commit": "0919dc39afb7d4ab22bfc14a431f0d55ef1b81b3", + "verified_on": "2026-10-03", + "rendered_to": "docs/SECURITY-profiles.md", + "renderer": "scripts/ci/render-egress-register.js", + "scope": "One row per route by which a prompt, code, a secret-adjacent string or a key can leave the container. The register catalogues routes; the same file's `paths` is the redaction contract for the two routes that implement it. A route with no gate or no accepted-egress record carries a marker. The register records the gap and does not invent a gate or a record.", + "leaves_vocabulary": [ + "prompt", + "code", + "secret-adjacent", + "public artefact" + ], + "destination_classes": [ + "LAN", + "vendor", + "public internet" + ], + "marker_vocabulary": { + "no-gate": "Nothing turns the route off short of removing a credential or a binary.", + "no-accepted-record": "No ADR or dated owner decision accepts what leaves on this route." + }, + "credential_class": "Provider credentials stay devuser-class in this step (owner disposition Q8, 2026-10-03, pending owner review). Each row names the credential it uses.", + "routes": [ + { + "id": "primary-model", + "route": "Claude Code and Codex sessions (the primary model routes)", + "leaves": [ + "prompt", + "code", + "secret-adjacent" + ], + "destination_class": "vendor", + "destination": "Anthropic API; OpenAI API", + "gate": "none. A running session is the egress. `[toolchains].claude` (`agentbox.toml:1849`) and `codex` (`agentbox.toml:1860`) are rebuild-class bake gates, not runtime switches.", + "accepted_egress": "Owner disposition Q14, 2026-10-03 (custody-isolation design §11, pending owner review): primary-model egress recorded as accepted. No ADR yet.", + "markers": [ + "no-gate" + ], + "notes": "Everything an agent reads can leave: prompts, file contents, tool output. Providers declared at `agentbox.toml:1165-1169` (Anthropic, oauth) and `agentbox.toml:1171-1173` (OpenAI). Credentials `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` are projected into the container environment (`flake.nix:3432`, `flake.nix:3434`): devuser-class this step (Q8)." + }, + { + "id": "skill-router", + "route": "Live skill router, `UserPromptSubmit` hook (ADR-2091)", + "leaves": [ + "prompt" + ], + "destination_class": "vendor", + "destination": "TypeSafe System One, `api.typesafe.ai` (`config/hooks/lib/skill-route.cjs:33`)", + "gate": "`[skills.routing].router = \"table\"` (`agentbox.toml:962`) de-registers the hook; `hook` (`agentbox.toml:963`). An absent `TYPESAFE_API_KEY` (`config/hooks/lib/skill-route.cjs:398`) fails open without sending.", + "accepted_egress": "ADR-2090 (accepted, `5e213c3ec`): routing prompt may leave, skill routing only (`docs/adr/ADR-2090-skill-routing-prompt-egress.md:31-40`); per-project gates deferred, not waived (`docs/adr/ADR-2090-skill-routing-prompt-egress.md:42-45`).", + "markers": [], + "notes": "The user's turn text, clamped head+tail (`config/hooks/lib/skill-route.cjs:408-414`), plus every routable skill description. No per-project bypass exists at HEAD; that is the debt ADR-2090 records. Credential `TYPESAFE_API_KEY`: devuser-class (Q8)." + }, + { + "id": "jev-compaction", + "route": "Jev compaction via factrail (ADR-2093 policy, ADR-2121 implementation)", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "TypeSafe System One (cloud) unless repointed at the local façade", + "gate": "`[features.jev_compaction].enabled` (`agentbox.toml:680`); `egress = \"metadata\"` (`agentbox.toml:701`) narrows the payload to kinds, key names, counts and lengths.", + "accepted_egress": "ADR-2093 (accepted): operator decision to send transcript windows beyond ADR-2090's routing-only scope, with email fenced out (`docs/adr/ADR-2093-jev-verbatim-compaction.md:53-54`).", + "markers": [], + "notes": "Tool-call arguments and results from the session transcript, capped per fitting stage. Email-tainted sessions never reach a cloud judge. Credential `TYPESAFE_API_KEY`: devuser-class (Q8)." + }, + { + "id": "system-one-local", + "route": "Sovereign System One façade (ADR-2094)", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "LAN", + "destination": "`system-one-facade` on `visionclaw_network`; option embeddings to `192.168.2.132:9997` (`agentbox.toml:737`)", + "gate": "`[features.sovereign_system_one].enabled` (`agentbox.toml:732`), currently `false`", + "accepted_egress": "ADR-2094 (accepted, activation inactive): a local façade in the Jev wire format (`docs/adr/ADR-2094-local-capacity-adapting-typed-decision-facade.md:35-38`).", + "markers": [], + "notes": "Carries the router and compaction payloads above. When on, nothing leaves the LAN. Inactive at HEAD, so the two vendor rows above are the live routes." + }, + { + "id": "loom-door", + "route": "Ontology Loom façade: ontology condense; dream nights when `llm_provider = \"loom\"`", + "leaves": [ + "prompt" + ], + "destination_class": "LAN", + "destination": "`${LOOM_BASE_URL}`: the estate façade `:8084` on the HP host. Compose default is `http://loom:8080/v1` (`flake.nix:3438`); the LAN address is deployment configuration.", + "gate": "`[skills.ontology.condense].enabled` (`agentbox.toml:860`); `[dream_machine].llm_provider` (`agentbox.toml:2179`) or `DREAM_LLM_PROVIDER`", + "accepted_egress": "ADR-2070 (accepted, live): the Loom endpoints are named egress doors and knowledge work holds `:8084` (`docs/adr/ADR-2070-one-front-door-is-ingress-the-loom-raw-port-is-a-named-egress-door.md:39-47`); `docs/LAN-door-threat-model.md:16`.", + "markers": [], + "notes": "Knowledge-graph class text and dream prompts. The trust is service identity, not remote attestation (`docs/LAN-door-threat-model.md:16`)." + }, + { + "id": "loom-raw", + "route": "Loom raw model door, `loom-raw` interaction-plane session", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "LAN", + "destination": "Loom raw model `:8085` on the HP host", + "gate": "none at runtime. The session seed (`agentbox.toml:1810`) is chosen per session by the operator or agent.", + "accepted_egress": "ADR-2070 (accepted): the raw door is agent-choice and benchmark-only (`docs/adr/ADR-2070-one-front-door-is-ingress-the-loom-raw-port-is-a-named-egress-door.md:48-52`); `docs/LAN-door-threat-model.md:17`.", + "markers": [ + "no-gate" + ], + "notes": "Raw coding sessions send their whole working context to the LAN model. The purpose restriction is policy, not runtime isolation (`docs/LAN-door-threat-model.md:17`)." + }, + { + "id": "routing-label-embeddings", + "route": "Routing teacher label log, EXP-B8 (ADR-2110)", + "leaves": [ + "prompt" + ], + "destination_class": "LAN", + "destination": "bge-small on Xinference, `192.168.2.132:9997` (`agentbox.toml:993`)", + "gate": "`[skills.routing].label_log` (`agentbox.toml:992`)", + "accepted_egress": "Owner decision 2026-10-02 R5b: label log on as a bounded experiment (`agentbox.toml:986-987`; `scripts/experiments/exp-b8-label-log.cjs:7-8`).", + "markers": [], + "notes": "Turn text is embedded for the shadow BM25 comparison. LAN only. The experiment switches itself off at its stopping rule." + }, + { + "id": "consultant-codex", + "route": "Consultant MCP `consultant-codex` (`/consult codex`, auto-consultant)", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "OpenAI, via the Codex CLI (`mcp/consultants/codex/server.js:39`)", + "gate": "`[consultants].enabled` (`agentbox.toml:1302`); `[consultants.codex].enabled` (`agentbox.toml:1307`)", + "accepted_egress": "none. ADR-011 (accepted, archived) designs the consultant tier; no record accepts sending repo context to the vendor.", + "markers": [ + "no-accepted-record" + ], + "notes": "Full consult prompts including repository context. Drift: ADR-011 (`docs/archive/adr/ADR-011-consultation-mcps.md:182`) and `mcp/consultants/README.md:102-104` claim outbound privacy-filter redaction. No redaction call exists in `mcp/consultants/shared/` at HEAD. Codex CLI credentials: devuser-class (Q8)." + }, + { + "id": "consultant-antigravity", + "route": "Consultant MCP `consultant-antigravity`", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "Google (Gemini), via the Antigravity CLI (`mcp/consultants/antigravity/server.js:38`)", + "gate": "`[consultants].enabled` (`agentbox.toml:1302`); `[consultants.antigravity].enabled` (`agentbox.toml:1313`)", + "accepted_egress": "none. ADR-011 designs the tier only.", + "markers": [ + "no-accepted-record" + ], + "notes": "As consultant-codex, including the privacy-filter drift. CLI credentials: devuser-class (Q8)." + }, + { + "id": "consultant-zai", + "route": "Consultant MCP `consultant-zai`", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "Z.AI (GLM), via the `claude-zai` CLI (`mcp/consultants/zai/server.js:41`)", + "gate": "`[consultants].enabled` (`agentbox.toml:1302`); `[consultants.zai].enabled` (`agentbox.toml:1319`)", + "accepted_egress": "none. ADR-011 designs the tier only.", + "markers": [ + "no-accepted-record" + ], + "notes": "As consultant-codex. Credential `ZAI_ANTHROPIC_API_KEY`: devuser-class (Q8)." + }, + { + "id": "consultant-perplexity", + "route": "Consultant MCP `consultant-perplexity`", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "`api.perplexity.ai` (`mcp/consultants/perplexity/server.js:17`)", + "gate": "`[consultants].enabled` (`agentbox.toml:1302`); `[consultants.perplexity].enabled` (`agentbox.toml:1326`)", + "accepted_egress": "none. ADR-011 designs the tier only.", + "markers": [ + "no-accepted-record" + ], + "notes": "As consultant-codex. Credential `PERPLEXITY_API_KEY` (`agentbox.toml:1189-1191`): devuser-class (Q8)." + }, + { + "id": "consultant-deepseek", + "route": "Consultant MCP `consultant-deepseek`", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "`api.deepseek.com` (`mcp/consultants/deepseek/server.js:18`)", + "gate": "`[consultants].enabled` (`agentbox.toml:1302`); `[consultants.deepseek].enabled` (`agentbox.toml:1331`)", + "accepted_egress": "none. ADR-011 designs the tier only.", + "markers": [ + "no-accepted-record" + ], + "notes": "As consultant-codex. DeepSeek API key: devuser-class (Q8)." + }, + { + "id": "zai-night", + "route": "Dream engine night provider (`services/dream-engine`)", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "Z.AI, `https://api.z.ai/api/anthropic` (`services/dream-engine/src/config.rs:381`)", + "gate": "`[dream_machine].llm_provider = \"loom\"` (`agentbox.toml:2179`) or `DREAM_LLM_PROVIDER` (`services/dream-engine/src/engine.rs:1687`); `[dream_machine].enabled` (`agentbox.toml:2163`)", + "accepted_egress": "none. No ADR or owner decision accepts the dream corpus leaving for Z.AI.", + "markers": [ + "no-accepted-record" + ], + "notes": "Nightly dream prompts built from workspace and repository context. Default provider is `zai`. Credential `ZAI_ANTHROPIC_API_KEY` (`services/dream-engine/src/engine.rs:1695`): devuser-class (Q8)." + }, + { + "id": "session-digest", + "route": "Mobile-bridge session digest (`egress-policy.json` path `session-digest`)", + "leaves": [ + "prompt" + ], + "destination_class": "vendor", + "destination": "`ZAI_URL` (Z.AI by default) for summarisation, then the cloud relay and the Solid pod", + "gate": "`[sovereign_mesh.mobile_bridge].enabled` (`agentbox.toml:98`), currently `false`; `AGENTBOX_SESSION_DIGEST` / `AGENTBOX_EGRESS` (`config/hooks/lib/egress-policy.cjs:145-148`)", + "accepted_egress": "ADR-030 D3 (accepted, archived): the mesh's one accepted external hop (`docs/archive/adr/ADR-030-sovereign-mesh-manifest-boundary.md:65-67`). The ADR-2026 contract is still `proposed`.", + "markers": [], + "notes": "Flattened transcript text, redacted before the provider hop. The signed kind-30840 is not encrypted." + }, + { + "id": "ontology-monitor", + "route": "Ontology monitor `SessionEnd` hook (`config/hooks/ontology-monitor.cjs`)", + "leaves": [ + "prompt", + "code" + ], + "destination_class": "vendor", + "destination": "Z.AI via the `claude-zai` CLI (`config/hooks/ontology-monitor.cjs:156`, `config/hooks/ontology-monitor.cjs:177`); proposals to the forum relay as kind 31402", + "gate": "`[ontology_monitor].enabled` (`agentbox.toml:116`); `mode = \"dryrun\"` (`agentbox.toml:117`) stops the publish", + "accepted_egress": "none. This contradicts ADR-030 D3's \"exactly one external data hop\" (`docs/archive/adr/ADR-030-sovereign-mesh-manifest-boundary.md:67`).", + "markers": [ + "no-accepted-record" + ], + "notes": "Changed files plus a transcript tail of up to 12,000 characters (`config/hooks/ontology-monitor.cjs:115`). Not routed through the ADR-2026 redactor or `AGENTBOX_EGRESS`." + }, + { + "id": "project-tracking", + "route": "Project tracking: primers, GitHub enrichment, kind-30841 publish (ADR-035)", + "leaves": [ + "code", + "public artefact" + ], + "destination_class": "vendor", + "destination": "Z.AI (primers); GitHub API (enrichment); the configured relay (kind 30841)", + "gate": "`[project_tracking].enabled` (`agentbox.toml:1668`); `github_enrichment` (`agentbox.toml:1671`); `primer_on_scan` (`agentbox.toml:1673`); `nostr_publish` (`agentbox.toml:1674`)", + "accepted_egress": "ADR-035 D5 (accepted, archived): fail-open nostr publish and GitHub enrichment (`docs/archive/adr/ADR-035-project-tracking-telemetry-and-nostr-kind.md:105`). The Z.AI primer hop is not separately accepted.", + "markers": [], + "notes": "Primers summarise repository content. Credential `GITHUB_TOKEN`: devuser-class (Q8)." + }, + { + "id": "live-mirror", + "route": "Per-turn session mirror, NIP-59 (`config/hooks/nostr-live-mirror.cjs`)", + "leaves": [ + "prompt" + ], + "destination_class": "public internet", + "destination": "One relay: `NOSTR_MIRROR_RELAY`, else the cloud default (`config/hooks/nostr-live-mirror.cjs:51`)", + "gate": "`AGENTBOX_LIVE_MIRROR`, `AGENTBOX_EGRESS` (`config/hooks/lib/egress-policy.cjs:145-148`); refused without a valid `AGENTBOX_MIRROR_RECIPIENTS` allowlist", + "accepted_egress": "ADR-029 D3 (accepted, archived): no LLM hop, cloud relay only (`docs/archive/adr/ADR-029-session-mirror-live-egress.md:67-71`). The ADR-2026 contract is still `proposed`.", + "markers": [], + "notes": "Selected turn text, redacted, then gift-wrapped (kind 1059) to the operator's derived child key." + }, + { + "id": "nostr-gateway", + "route": "Nostr control gateway, operator command DMs (`config/nostr-gateway/gateway.cjs`)", + "leaves": [ + "prompt" + ], + "destination_class": "public internet", + "destination": "Cloud relay (`config/nostr-gateway/gateway.cjs:84`)", + "gate": "`AGENTBOX_NOSTR_GATEWAY=0` (`config/nostr-gateway/gateway.cjs:166`; `flake.nix:2087`)", + "accepted_egress": "none.", + "markers": [ + "no-accepted-record" + ], + "notes": "Replies carry session output from the tmux fleet, gift-wrapped to the operator. Not routed through the ADR-2026 redactor. The operator key `AGENTBOX_PRIVKEY_HEX` is in the process environment." + }, + { + "id": "junkiejarvis-posts", + "route": "JunkieJarvis forum replies and clarifying DMs (`management-api/lib/junkiejarvis-agent.js`)", + "leaves": [ + "public artefact" + ], + "destination_class": "public internet", + "destination": "The forum relay (`NOSTR_RELAYS`)", + "gate": "`[sovereign_mesh].junkiejarvis` (`agentbox.toml:50`), the only switch", + "accepted_egress": "ADR-030 (accepted, archived) places JunkieJarvis in the mesh; ADR-2088 (accepted, staged) accepts the clarifying DMs.", + "markers": [], + "notes": "Kind-42 replies in forum sections and kind-1059 DMs. Encrypted to the zone key where the zone is encrypted." + }, + { + "id": "junkiejarvis-llm", + "route": "JunkieJarvis reply generation", + "leaves": [ + "prompt" + ], + "destination_class": "vendor", + "destination": "Anthropic (`management-api/lib/junkiejarvis-agent.js:466`), else Z.AI or OpenAI (`management-api/lib/junkiejarvis-agent.js:496-497`)", + "gate": "`[sovereign_mesh].junkiejarvis` (`agentbox.toml:50`)", + "accepted_egress": "none. This contradicts ADR-030 D3 (`docs/archive/adr/ADR-030-sovereign-mesh-manifest-boundary.md:67`).", + "markers": [ + "no-accepted-record" + ], + "notes": "Forum members' messages (third-party content) and the agent's context. Credentials `ANTHROPIC_API_KEY` / `ZAI_ANTHROPIC_API_KEY`: devuser-class (Q8)." + }, + { + "id": "dream-forum-suggestions", + "route": "Dream forum-suggestions tenant (`scripts/dream-forum-suggestions.mjs`)", + "leaves": [ + "prompt", + "public artefact" + ], + "destination_class": "vendor", + "destination": "Z.AI triage (`scripts/dream-forum-suggestions.mjs:57`); replies to the forum relay (`scripts/dream-forum-suggestions.mjs:48-49`)", + "gate": "`DREAM_FORUM_SUGGESTIONS=0` (`scripts/dream-machine-nightly.mjs:700`); clarifying DMs held while `[sovereign_mesh].junkiejarvis` is off (`scripts/dream-forum-suggestions.mjs:220`)", + "accepted_egress": "ADR-2088 (accepted) covers the clarifying DMs. None for the Z.AI triage hop.", + "markers": [ + "no-accepted-record" + ], + "notes": "Forum suggestion text goes to Z.AI. Replies are posted in-thread as JunkieJarvis." + }, + { + "id": "dream-digest", + "route": "Nightly dream digest forum post (`services/dream-engine/src/digest.rs`)", + "leaves": [ + "public artefact" + ], + "destination_class": "public internet", + "destination": "Forum relay, `DREAM_RELAY` else the cloud default (`services/dream-engine/src/relay.rs:32-38`)", + "gate": "`DREAM_DIGEST=0` (`services/dream-engine/src/engine.rs:312`)", + "accepted_egress": "none.", + "markers": [ + "no-accepted-record" + ], + "notes": "Kind-42 summary of the night. Encrypted to the zone key or refused, never plaintext in an encrypted zone (`services/dream-engine/src/digest.rs:412-424`)." + }, + { + "id": "dream-governance", + "route": "Dream governance panel and cases, kinds 31400/31402 (`services/dream-engine/src/governance.rs`)", + "leaves": [ + "public artefact" + ], + "destination_class": "public internet", + "destination": "Forum relay (`services/dream-engine/src/governance.rs:9-11`)", + "gate": "`DREAM_GOVERNANCE=0` (`services/dream-engine/src/governance.rs:82-83`)", + "accepted_egress": "none.", + "markers": [ + "no-accepted-record" + ], + "notes": "One case per open inbox item, signed by JunkieJarvis. Admin replies (31403) are ingested the next night." + }, + { + "id": "exp-b8-report", + "route": "EXP-B8 stop report and gate-flip PR (`scripts/experiments/exp-b8-label-log.cjs`)", + "leaves": [ + "public artefact", + "code" + ], + "destination_class": "public internet", + "destination": "Forum relay (`scripts/experiments/exp-b8-label-log.cjs:445`); a GitHub PR", + "gate": "`[skills.routing].label_log` (`agentbox.toml:992`); `--dry-run` never posts", + "accepted_egress": "Owner decision 2026-10-02 R5b: report on the forum when the stopping rule fires (`scripts/experiments/exp-b8-label-log.cjs:7-8`).", + "markers": [], + "notes": "One kind-42 summary, zone-encrypted or refused (`scripts/experiments/exp-b8-label-log.cjs:548`), at most once." + }, + { + "id": "sidestr-announce", + "route": "sidestr producer tips, kind 33333 (`config/sidechain/run-producer.sh`)", + "leaves": [ + "public artefact" + ], + "destination_class": "public internet", + "destination": "Five public relays (`config/sidechain/run-producer.sh:49`)", + "gate": "`[sidechain].enabled` (`agentbox.toml:1573`), rebuild-class", + "accepted_egress": "none accepted. ADR-2098 registers kind 33333 but is `proposed`.", + "markers": [ + "no-accepted-record" + ], + "notes": "Public chain data, not a prompt. Recorded for completeness. Signer key under `/var/lib/agentbox/secrets`." + }, + { + "id": "sidestr-pages", + "route": "sidestr Pages mirror (`config/sidechain/mirror-sync.sh`)", + "leaves": [ + "public artefact" + ], + "destination_class": "public internet", + "destination": "GitHub Pages checkout, `git push` (`config/sidechain/mirror-sync.sh:81`); announced URL `agentbox.toml:1574`", + "gate": "`[sidechain].mirror` (`agentbox.toml:1575`), rebuild-class", + "accepted_egress": "none accepted. ADR-2098 and ADR-2103 are `proposed`.", + "markers": [ + "no-accepted-record" + ], + "notes": "Block files only. Uses the devuser `gh` credential (`flake.nix:2692`)." + }, + { + "id": "github", + "route": "`gh` and `git push` from agent sessions", + "leaves": [ + "code", + "public artefact" + ], + "destination_class": "vendor", + "destination": "GitHub", + "gate": "none", + "accepted_egress": "none.", + "markers": [ + "no-gate", + "no-accepted-record" + ], + "notes": "Every agent can push via the `gh` credential helper (`config/entrypoint-unified.sh:388-391`). Credential `GITHUB_TOKEN` (`flake.nix:3433`): devuser-class (Q8). Private repositories land in vendor custody; public ones are published." + }, + { + "id": "crates-io", + "route": "`cargo publish`", + "leaves": [ + "public artefact", + "code" + ], + "destination_class": "public internet", + "destination": "crates.io", + "gate": "none. The credential is written whenever `CRATES_TOKEN` is set (`config/entrypoint-unified.sh:3072-3077`).", + "accepted_egress": "ADR-2106 (accepted) for `sidestr-*` crates only, each publication approved by the owner case by case (`docs/adr/ADR-2106-sidestr-crates-are-agpl-derivatives-of-siding-published-and-consumed.md:36-47`). Any other crate has none.", + "markers": [ + "no-gate" + ], + "notes": "Publication is irreversible (yank only hides a version). Credential `CRATES_TOKEN`: devuser-class (Q8)." + }, + { + "id": "cloudflare", + "route": "`wrangler` (Workers deploy, D1, `secret put`) and the forum backup cron", + "leaves": [ + "code", + "secret-adjacent" + ], + "destination_class": "vendor", + "destination": "Cloudflare API", + "gate": "none. `wrangler` is always baked (`flake.nix:531-533`); the backup cron is unconditional (`flake.nix:2643-2649`).", + "accepted_egress": "none.", + "markers": [ + "no-gate", + "no-accepted-record" + ], + "notes": "`wrangler secret put` sends secret values by design. The backup pulls data in but presents the token. Credentials `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`: devuser-class (Q8)." + }, + { + "id": "tailscale", + "route": "`tailscaled` (`flake.nix:2447-2450`)", + "leaves": [ + "secret-adjacent" + ], + "destination_class": "vendor", + "destination": "Tailscale coordination plane; peers on the tailnet", + "gate": "`[networking].tailscale` (`agentbox.toml:282`), currently `true`", + "accepted_egress": "none. ADR-007 (accepted, archived) only covers `tailscaled` running as root; ADR-045 is `proposed`.", + "markers": [ + "no-accepted-record" + ], + "notes": "Node identity and metadata go to the vendor. Tailnet traffic bypasses the did:nostr boundary (`agentbox.toml:266-272`). The manifest comment says off by default (`agentbox.toml:277-281`) but the value is `true`. Credential `TAILSCALE_AUTHKEY`: devuser-class (Q8)." + }, + { + "id": "mcp-research", + "route": "Research MCPs: `web-researcher`, `perplexity` (MCP hub)", + "leaves": [ + "prompt" + ], + "destination_class": "vendor", + "destination": "Search and answer vendors (Perplexity and the web-researcher backends)", + "gate": "`[skills.research].web_researcher` (`agentbox.toml:581`), rebuild-class; `perplexity` sits in the MCP hub list (`agentbox.toml:1269-1272`) with no runtime switch", + "accepted_egress": "none.", + "markers": [ + "no-accepted-record" + ], + "notes": "Search queries written by agents, often paraphrasing the task. Credential `PERPLEXITY_API_KEY`: devuser-class (Q8)." + } + ] + } } diff --git a/config/entrypoint-unified.sh b/config/entrypoint-unified.sh index 393a9e8f2..4e10d65cf 100644 --- a/config/entrypoint-unified.sh +++ b/config/entrypoint-unified.sh @@ -166,8 +166,6 @@ _ab_vault_resolve() { echo "[vault] root=$VAULT_ROOT repo=${VAULT_REPO:-—} pages=$VAULT_PAGES format=$VAULT_FORMAT tui=$VAULT_TUI working=${VAULT_WORKING_ROOT:-—} transcripts=${VAULT_TRANSCRIPTS:-—}" } -# ADR-2029 D4: until the image bakes Rune, the bind-mounted cargo bin dir is -# where `rune` lives. One line, fail-open — absent dir changes nothing. # Run a command as devuser from the root-owned boot path. The image ships # util-linux for `setpriv`; fall back to `runuser`, then `su`, and fail LOUDLY # when none exists — the 2026-09-03 image had neither runuser nor su and three @@ -191,11 +189,88 @@ run_as_devuser() { fi } -_ab_cargo_bin_on_path() { - [ -d "/home/devuser/workspace/.cargo/bin" ] && case ":$PATH:" in *":/home/devuser/workspace/.cargo/bin:"*) :;; *) PATH="/home/devuser/workspace/.cargo/bin:$PATH"; export PATH;; esac +# --------------------------------------------------------------------------- +# Root boot PATH: store-only (custody X-1 step 1, W0, bypass 2) +# --------------------------------------------------------------------------- +# Both stages run as root. Until W0 the boot PREPENDED the devuser-owned +# /home/devuser/workspace/.cargo/bin to PATH (ADR-2029 D4, for rune), so a +# binary planted there under the name of any tool the boot calls (mkdir, node, +# jq ...) ran as root, and `supervisorctl start bootstrap` let devuser trigger it +# at will. rune is baked now (lib/rune.nix). Root keeps only /nix/store entries +# and the image's fixed system dirs, in their original order; anything relative, +# dotted, globbed or outside that set is dropped. devuser shells get the cargo +# bin APPENDED by the Phase-8 runtime-env and fish snippets, never root. +# Test: tests/runtime-contract/RC-X1-01.sh. +_ab_root_path_sanitise() { # _ab_root_path_sanitise → trusted PATH on stdout + local out="" e + local -a parts=() + IFS=: read -r -a parts <<<"$1" + for e in "${parts[@]}"; do + case "$e" in + *..*|*[*?[]*) continue ;; + /nix/store/?*|/usr/local/bin|/usr/local/sbin|/bin|/sbin|/usr/bin|/usr/sbin) ;; + *) continue ;; + esac + case ":$out:" in *":$e:"*) continue ;; esac + out="${out:+$out:}$e" + done + printf '%s' "${out:-/usr/local/bin:/bin:/usr/bin}" +} +PATH="$(_ab_root_path_sanitise "$PATH")" +export PATH + +# --------------------------------------------------------------------------- +# Stage B is one-shot per container start (custody X-1 step 1, W0, bypass 2) +# --------------------------------------------------------------------------- +# [program:bootstrap] runs Stage B as root and devuser holds supervisor control +# (Q10), so `supervisorctl start bootstrap` could replay it. Stage A arms a +# root-owned state dir; Stage B claims it with an atomic mkdir; every later claim +# is a no-op. The dir lives in /tmp, NOT /run: /run, /var/run and /var/log are +# tmpfs mounts owned by uid 1000 (flake.nix baselineTmpfsMounts), so devuser can +# rename any entry directly under them, root's included. /tmp is root 1777: the +# sticky bit stops devuser renaming or unlinking root's entries, and it is a +# fresh tmpfs per container start. Custody W1 (ADR-2122): with +# [security].role_isolation on and /run/secrets its own root-owned mount, the +# guard moves to /run/secrets/.root-guard (ab_root_state_dir_pick, below); with +# the flag off it stays here, because the flag-off boot hands /run/secrets to +# devuser. Tests: tests/runtime-contract/RC-X1-02.sh, tests/config/role-isolation-boot.test.sh. +AB_ROOT_STATE_DIR=/tmp/.agentbox-root +_ab_root_state_dir_ok() { # _ab_root_state_dir_ok [owner-uid=0] → 0 when trustworthy + [ -d "$1" ] && [ ! -L "$1" ] || return 1 + [ "$(stat -c '%u %a' -- "$1" 2>/dev/null)" = "${2:-0} 700" ] +} +_ab_root_state_dir_prepare() { # _ab_root_state_dir_prepare [owner-uid=0] — Stage A only + local d="$1" uid="${2:-0}" + if [ -e "$d" ] || [ -L "$d" ]; then + if ! _ab_root_state_dir_ok "$d" "$uid"; then + # Squatted (wrong owner, loose mode or a symlink): move it aside, never follow it. + mv -T -- "$d" "${d}.squatted.$$" || return 1 + echo "[security] moved an untrusted ${d} aside to ${d}.squatted.$$" >&2 + fi + fi + if [ ! -d "$d" ]; then + (umask 077 && mkdir -- "$d") || return 1 + fi + # A new container start re-arms Stage B (matters only where /tmp is not a tmpfs). + rmdir -- "${d}/stage-b.claimed" 2>/dev/null || true + _ab_root_state_dir_ok "$d" "$uid" +} +_ab_stage_b_claim() { # _ab_stage_b_claim [owner-uid=0] → 0 claimed, 1 already ran, 2 refuse + _ab_root_state_dir_ok "$1" "${2:-0}" || return 2 + mkdir -- "${1}/stage-b.claimed" 2>/dev/null || return 1 return 0 } +# Role custody (ADR-2122, custody X-1 step 1, W1): the /run/secrets delivery and +# the supervisor-config pick. Definitions only. Every caller is behind +# [security].role_isolation = true; a missing lib turns the flag off (Stage A, +# Phase 1) so the boot stays today's. +_AB_ROLE_CUSTODY_LIB=/opt/agentbox/config/lib/role-custody.sh +if [ -r "$_AB_ROLE_CUSTODY_LIB" ]; then + # shellcheck source=lib/role-custody.sh + . "$_AB_ROLE_CUSTODY_LIB" +fi + # Stage dispatch — when running as the supervisord bootstrap program, skip to B. if [ "${AGENTBOX_BOOTSTRAP_STAGE:-A}" = "B" ]; then echo "[bootstrap] Stage B — installing runtime dependencies" @@ -262,9 +337,32 @@ export CODEX_HOME="${CODEX_HOME:-/home/devuser/.codex}" export GIT_CONFIG_GLOBAL="${GIT_CONFIG_GLOBAL:-/home/devuser/.config/git/config}" # ADR-2028: [vault] is resolved here, before any consumer runs, so every # supervised program inherits VAULT_ROOT/VAULT_PAGES/VAULT_FORMAT/VAULT_TUI -# from PID 1. ADR-2029 D4 puts the bind-mounted cargo bin dir on PATH. +# from PID 1. PATH stays store-only for root (sanitised above the stage +# dispatch); the workspace cargo bin reaches devuser shells via Phase 8 only. _ab_vault_resolve -_ab_cargo_bin_on_path +# Custody X-1 step 1: [security].role_isolation (boot-class, default false). Read +# once here and exported, so supervisord and every program see the same value; +# the manifest wins over anything inherited from the compose environment. +AGENTBOX_ROLE_ISOLATION="$(_ab_toml_bool security role_isolation)" +# Custody W1 (ADR-2122). Flag on: /run/secrets must be root:root 0711 before +# anything is written into it, and the Stage-B guard moves inside it when it is a +# real mount. An image that cannot honour the flag (no lib, no isolated config or +# no plan) boots exactly as with the flag off, and says so loudly. +_AB_SECRETS_MOUNT_STATE=ok +if [ "$AGENTBOX_ROLE_ISOLATION" = 1 ]; then + if declare -F ab_role_isolation_ready >/dev/null && ab_role_isolation_ready; then + ab_secrets_root_prepare /run/secrets || _AB_SECRETS_MOUNT_STATE=degraded + AB_ROOT_STATE_DIR="$(ab_root_state_dir_pick 1)" + else + echo "[security] ROLE-ISOLATION-UNAVAILABLE: [security].role_isolation = true, but this image lacks ${_AB_ROLE_CUSTODY_LIB}, /etc/supervisord.roles.conf or /etc/agentbox/role-secrets.tsv; booting as role_isolation = false" >&2 + AGENTBOX_ROLE_ISOLATION=0 + fi +fi +export AGENTBOX_ROLE_ISOLATION +# Arm Stage B's one-shot guard (see _ab_stage_b_claim). Fail loud, not fatal: +# an unarmed dir makes Stage B refuse, which shows in bootstrap.error.log. +_ab_root_state_dir_prepare "$AB_ROOT_STATE_DIR" \ + || echo "[security] ERROR: could not prepare ${AB_ROOT_STATE_DIR}; Stage B will refuse to run" >&2 echo "[1/8] Preparing runtime directories..." mkdir -p \ @@ -284,8 +382,111 @@ mkdir -p \ # decrypted Nostr bridge key written in Phase 5c). Tight perms — only devuser # (the uid services run as) may traverse it. This is the ONLY place root creates # it; all secret writes happen in the root boot phase before the drop to devuser. -chmod 0700 /run/secrets 2>/dev/null || true -chown 1000:1000 /run/secrets 2>/dev/null || true +# Custody W1: with [security].role_isolation on, /run/secrets stays root:root +# 0711 (prepared above) and each role gets its own 0500 subdirectory before +# supervisord starts (ab_role_secrets_deliver, just before the exec). +if [ "$AGENTBOX_ROLE_ISOLATION" != 1 ]; then + chmod 0700 /run/secrets 2>/dev/null || true + chown 1000:1000 /run/secrets 2>/dev/null || true +fi + +# --------------------------------------------------------------------------- +# Custody W2 (bypass 3): ROLE secrets leave PID 1's environment +# --------------------------------------------------------------------------- +# compose env_file feeds .env into PID 1 and supervisord hands its whole +# environment to every child, so a signing key in .env sat in every agent shell, +# every MCP server, and every `env` an agent ever printed into a transcript. +# Under [security].role_isolation each ROLE variable is handed to a file and +# unset from this process before anything else reads it: the identity bootstrap +# included, because it reads _FILE under the flag. The variable names and +# their roles are the ROLE set of config/custody/env-classes.json; the inventory +# (scripts/ci/env-secret-inventory.js --check) fails if this list drifts from it. +# +# Seam with W1 (which owns /run/secrets delivery from the volumes): when +# _FILE already names a delivered file, capture only unsets . The +# owner of a delivered file is the role's own account once W1 bakes it, and +# devuser (the uid every reader runs as today) until then. Flag off: every +# function below returns at its first line, so the environment supervisord +# receives is byte-identical to before (tests/runtime-contract/RC-X1-06.sh). +_AB_ROLE_ENV_VARS="AGENTBOX_AGENT_PRIVKEY_HEX:ab-identity AGENTBOX_BRIDGE_SK:ab-identity AGENTBOX_NSEC:ab-identity AGENTBOX_PRIVKEY_HEX:ab-identity AGENT_PRIVKEY_HEX:ab-identity CONCIERGE_PRIVKEY_HEX:ab-identity JUNKIEJARVIS_PRIVKEY_HEX:ab-identity NIP98_PROXY_ALLOW_BEARER:ab-ingress NIP98_PROXY_SESSION_SECRET:ab-ingress OPERATOR_NOSTR_PRIVKEY:ab-identity TAILSCALE_AUTHKEY:root" + +_ab_role_owner() { # _ab_role_owner → uid:gid that owns the role's delivered files + case "$1" in root) echo 0:0; return 0 ;; esac + local u g + if u="$(id -u "$1" 2>/dev/null)" && g="$(id -g "$1" 2>/dev/null)"; then + echo "${u}:${g}" + else + echo 1000:1000 + fi +} + +_ab_role_env_capture() { # _ab_role_env_capture [secrets-root=/run/secrets] + [ "$1" = 1 ] || return 0 + local root="${2:-/run/secrets}" tok name role fvar dir file dirs="" + for tok in $_AB_ROLE_ENV_VARS; do + name="${tok%%:*}"; role="${tok#*:}"; fvar="${name}_FILE" + [ -n "${!name+x}" ] || continue + if [ -n "${!fvar:-}" ] && [ -f "${!fvar}" ] && [ ! -L "${!fvar}" ]; then + unset "$name" + echo "[security] role-env: ${name} already delivered by file (${fvar}); unset from PID 1" + continue + fi + if [ -z "${!name}" ]; then + unset "$name" + continue + fi + dir="${root}/${role}" + # A fresh tmpfs holds nothing here; anything that is not a plain directory + # we created is replaced rather than followed. + if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then rm -f -- "$dir"; fi + mkdir -p -- "$root"; [ -d "$dir" ] || mkdir -m 0700 -- "$dir" + chown 0:0 "$dir" 2>/dev/null || true + chmod 0700 "$dir" + file="${dir}/${name}" + rm -f -- "$file" + if ! ( umask 077; set -o noclobber; printf '%s' "${!name}" >"$file" ); then + unset "$name" + echo "[security] ROLE-ISOLATION-DEGRADED role-env: ${name} could not be delivered; unset anyway" >&2 + continue + fi + chown "$(_ab_role_owner "$role")" "$file" 2>/dev/null || true + chmod 0400 "$file" + export "${fvar}=${file}" + unset "$name" + case " $dirs " in *" ${dir}:${role} "*) ;; *) dirs="${dirs} ${dir}:${role}" ;; esac + echo "[security] role-env: ${name} -> ${file} (0400, role ${role}); unset from PID 1" + done + for tok in $dirs; do + dir="${tok%:*}"; role="${tok##*:}" + chown "$(_ab_role_owner "$role")" "$dir" 2>/dev/null || true + chmod 0500 "$dir" + done + return 0 +} + +_ab_role_env_scrub() { # _ab_role_env_scrub — last line of defence before exec + [ "$1" = 1 ] || return 0 + local tok name n=0 + for tok in $_AB_ROLE_ENV_VARS; do + name="${tok%%:*}" + if [ -n "${!name+x}" ]; then + echo "[security] ROLE-ISOLATION-LEAK ${name} was in PID 1's environment at exec; unset" >&2 + unset "$name" + n=$((n + 1)) + fi + done + [ "$n" -eq 0 ] && echo "[security] role-env: PID 1's environment carries no ROLE variable" + return 0 +} + +_ab_role_key_file_own() { # _ab_role_key_file_own + [ "$1" = 1 ] || return 0 + [ -n "${2:-}" ] && [ -f "$2" ] && [ ! -L "$2" ] || return 0 + chown "$(_ab_role_owner "$3")" "$2" 2>/dev/null || true + chmod 0400 "$2" 2>/dev/null || true +} + +_ab_role_env_capture "$AGENTBOX_ROLE_ISOLATION" /run/secrets # R-012: Boot assertion — WORKSPACE must be a mounted volume, not tmpfs. # If the compose mount is missing, $WORKSPACE silently lands on the container @@ -342,6 +543,9 @@ for _vol_root in \ /home/devuser/.gemini \ /var/cache \ /var/cache/ruflo-plugins; do + # Custody W2b: under [security].role_isolation the secrets volume root is + # root:root 0700 (ab_custody_migrate, before Phase 3); never hand it back here. + [ "$AGENTBOX_ROLE_ISOLATION" = 1 ] && [ "$_vol_root" = /var/lib/agentbox/secrets ] && continue if [ -d "$_vol_root" ]; then # Only chown the root, not -R. If the dir is already uid 1000, this # is a no-op kernel call. Crucially: Docker auto-creates the parent @@ -369,13 +573,83 @@ if [ -d "$WORKSPACE" ]; then chown -R 1000:1000 "$WORKSPACE/project/.git/worktrees" 2>/dev/null || true fi -# Docker socket: make accessible to devuser (gid 965 on host, not mapped -# inside container). group_add in compose only affects PID 1's supplementary -# groups, not processes that later switch to devuser via supervisord user=. -# chmod o+rw is safe here — this is a single-user dev container. -if [ -S /var/run/docker.sock ]; then - chmod o+rw /var/run/docker.sock 2>/dev/null || true -fi +# Docker socket (custody X-1 step 1, W0, bypass 1). The host's docker group is +# gid 965 and is not mapped inside the container; compose group_add reaches only +# PID 1, not programs that drop to devuser via supervisord user=. +# role_isolation off: widen the socket o+rw for devuser, exactly as before. +# role_isolation on: do NOT widen it. The Docker daemon is root on the host, +# so socket access voids every in-container boundary. devuser gets the +# GET-only proxy at /run/docker-ro.sock ([program:docker-read-proxy]). +# The socket is a bind of the HOST inode, and earlier boots already widened +# it; skipping the chmod does not narrow it (risk R2). This never narrows it +# either: that is a host-side decision (Q2). If devuser can still reach it, +# the boot records degraded:docker-socket and logs a grep-able marker: +# fail loud, not fatal, like the vault gate. +# Test: tests/runtime-contract/RC-X1-04.sh. +_ab_docker_socket_access() { # _ab_docker_socket_access <0|1> [state-file] [user=devuser] + local sock="$1" iso="$2" state="${3:-/run/secrets/role-isolation.state}" user="${4:-devuser}" mode gid uid verdict + [ -S "$sock" ] || return 0 + if [ "$iso" != 1 ]; then + chmod o+rw "$sock" 2>/dev/null || true + return 0 + fi + mode="$(stat -L -c %a -- "$sock" 2>/dev/null || echo 0)" + gid="$(stat -L -c %g -- "$sock" 2>/dev/null || echo 0)" + uid="$(stat -L -c %u -- "$sock" 2>/dev/null || echo 0)" + verdict=ok + # devuser can connect if it owns the socket, is in its group, or other has w. + case "${mode: -1}" in [2367]) verdict=degraded ;; esac + [ "$uid" = "$(id -u "$user" 2>/dev/null || echo none)" ] && verdict=degraded + case " $(id -G "$user" 2>/dev/null) " in *" ${gid} "*) verdict=degraded ;; esac + if [ "$verdict" = degraded ]; then + echo "[security] ROLE-ISOLATION-DEGRADED docker-socket: ${sock} is mode ${mode} gid ${gid}; devuser can still drive the host Docker daemon. Host-side fix (R2/Q2): chmod 0660 /var/run/docker.sock on the host." >&2 + else + echo "[security] role_isolation: ${sock} not widened; devuser uses /run/docker-ro.sock (read-only)" + fi + # Never write through a planted symlink: remove, then create with O_EXCL (noclobber). + rm -f -- "$state" 2>/dev/null || true + ( set -C; printf '%s:docker-socket\n' "$verdict" >"$state" ) 2>/dev/null \ + || echo "[security] WARN: could not record ${verdict}:docker-socket in ${state}" >&2 + return 0 +} +_ab_docker_socket_access /var/run/docker.sock "$AGENTBOX_ROLE_ISOLATION" + +# devuser's sudo route (custody X-1, W10). flake.nix bakes /etc/group and +# /etc/sudoers{,.d} with the BUILD-time [security].role_isolation +# (config/bake-devuser-privilege.sh): built on, devuser is in neither wheel nor +# root and no sudoers line grants it NOPASSWD. The flag is boot-class, so an +# image built off can boot with it on; /etc is read-only and cannot be fixed +# here. no-new-privileges:true still neuters the setuid bit, so this is fail +# loud, not fatal, like the docker socket: record degraded:devuser-sudo and log +# a grep-able marker naming the rebuild. Reads files only; flag off: no-op. +# Conservative: any NOPASSWD grant to the user, %user, %wheel or ALL counts. +# Test: tests/runtime-contract/RC-X1-07.sh. +_ab_devuser_privilege_check() { # _ab_devuser_privilege_check <0|1> [etc=/etc] [state-file] [user=devuser] + local iso="$1" etc="${2:-/etc}" state="${3:-/run/secrets/role-isolation.state}" user="${4:-devuser}" why="" f verdict=ok + [ "$iso" = 1 ] || return 0 + if awk -F: -v u="$user" '($1 == "wheel" || $3 == "0") { n = split($4, m, ","); for (i = 1; i <= n; i++) if (m[i] == u) hit = 1 } + END { exit !hit }' "${etc}/group" 2>/dev/null; then + why="a member of wheel or root (${etc}/group)" + fi + for f in "${etc}/sudoers" "${etc}"/sudoers.d/*; do + [ -f "$f" ] || continue + if grep -Eq "^[[:space:]]*(${user}|%${user}|%wheel|ALL)[[:space:]].*NOPASSWD" "$f" 2>/dev/null; then + why="${why:+${why}; }granted NOPASSWD by ${f}" + fi + done + if [ -n "$why" ]; then + verdict=degraded + echo "[security] ROLE-ISOLATION-DEGRADED devuser-sudo: ${user} is ${why}. This image was built with [security].role_isolation = false and /etc is read-only; rebuild with the flag on (./agentbox.sh rebuild) to remove the sudo route." >&2 + else + echo "[security] role_isolation: ${user} has no sudo route (not in wheel or root; no NOPASSWD grant)" + fi + # Appended beside docker-socket. Under the flag the state lives in the root-owned + # 0711 /run/secrets tmpfs, where devuser cannot plant a link. + printf '%s:devuser-sudo\n' "$verdict" >>"$state" 2>/dev/null \ + || echo "[security] WARN: could not record ${verdict}:devuser-sudo in ${state}" >&2 + return 0 +} +_ab_devuser_privilege_check "$AGENTBOX_ROLE_ISOLATION" # Claude-flow data directory (hooks write here as devuser) mkdir -p /home/devuser/.claude-flow/data 2>/dev/null || true @@ -434,6 +708,24 @@ if [ "${ENABLE_DESKTOP:-false}" = "true" ]; then chmod 1777 /tmp/.X11-unix 2>/dev/null || true fi +# --------------------------------------------------------------------------- +# Custody W2b (design 3.2-3.4): at-rest secret copies +# --------------------------------------------------------------------------- +# After /run/secrets is prepared (Phase 1) and the volume roots are chowned, +# before the identity bootstrap reads or writes the identities volume. The +# registry is the atrest/atrestdir rows of /etc/agentbox/role-secrets.tsv. +# Flag on: copy each legacy copy to its canonical volume path once (verified, +# legacy left in place), hand each copy to its role 0400 and the volume dirs to +# root. Flag off: hand back to devuser whatever an earlier flag-on boot moved; +# on a volume that was never migrated this changes nothing. Fail-open both ways. +if declare -F ab_custody_migrate >/dev/null && [ -r /etc/agentbox/role-secrets.tsv ]; then + if [ "$AGENTBOX_ROLE_ISOLATION" = 1 ]; then + ab_custody_migrate /etc/agentbox/role-secrets.tsv /run/secrets/role-isolation.migrated + else + ab_custody_revert /etc/agentbox/role-secrets.tsv + fi +fi + # --------------------------------------------------------------------------- # Phase 3 — Sovereign mesh identity bootstrap # --------------------------------------------------------------------------- @@ -458,8 +750,13 @@ nostr-pod-bridge bootstrap # management-api runs as, and its only other reader) at 0600. Fail-open here: # a file the signer cannot read makes the pods slot fail closed with a typed # SigningUnavailable naming the file (ADR-2064), which is the accepted signal. +# Custody W2b: under [security].role_isolation it is ab-identity's, 0400 (the +# pods signer goes through the identity port), the same as the migrate step left +# it; a bootstrap that rewrote it must not hand it back to devuser. _SOVEREIGN_ID_FILE="$AGENTBOX_IDENTITY_ROOT/${AGENTBOX_AGENT_ID:-agentbox-core}.json" -if [ -f "$_SOVEREIGN_ID_FILE" ]; then +if [ "$AGENTBOX_ROLE_ISOLATION" = 1 ]; then + _ab_role_key_file_own 1 "$_SOVEREIGN_ID_FILE" ab-identity +elif [ -f "$_SOVEREIGN_ID_FILE" ]; then chown devuser:devuser "$_SOVEREIGN_ID_FILE" 2>/dev/null || true chmod 0600 "$_SOVEREIGN_ID_FILE" 2>/dev/null || true fi @@ -766,6 +1063,11 @@ fi if [ -f /run/agentbox/identity.env ]; then . /run/agentbox/identity.env fi +# Custody W2: under role isolation identity.env is public-only (no +# AGENTBOX_NSEC, no AGENTBOX_BRIDGE_SK). The bootstrap wrote the relay key to +# AGENTBOX_BRIDGE_SK_FILE itself; hand that file to its role here. Flag off this +# is a no-op and the SEC-003 block below runs exactly as before. +_ab_role_key_file_own "$AGENTBOX_ROLE_ISOLATION" "${AGENTBOX_BRIDGE_SK_FILE:-}" ab-identity # SEC-003: Do NOT propagate the decrypted Nostr secret key as an env var into # long-running processes (env is readable via /proc//environ by anything @@ -774,8 +1076,11 @@ fi # launcher's environment before exec'ing supervisord. nostr-pod-bridge reads # AGENTBOX_BRIDGE_SK_FILE (see services/nostr-pod-bridge/src/main.rs); the env # var remains supported only as a back-compat fallback. +# Custody W1: with [security].role_isolation on, this devuser copy is NOT +# written; the key goes only to /run/secrets/ab-identity/nostr.key (the delivery +# plan, just before the exec), which also unsets AGENTBOX_BRIDGE_SK. NOSTR_KEY_FILE="/run/secrets/nostr.key" -if [ -n "${AGENTBOX_BRIDGE_SK:-}" ]; then +if [ -n "${AGENTBOX_BRIDGE_SK:-}" ] && [ "$AGENTBOX_ROLE_ISOLATION" != 1 ]; then ( umask 077; printf '%s' "$AGENTBOX_BRIDGE_SK" > "$NOSTR_KEY_FILE" ) chmod 0400 "$NOSTR_KEY_FILE" 2>/dev/null || true chown 1000:1000 "$NOSTR_KEY_FILE" 2>/dev/null || true @@ -893,8 +1198,28 @@ if [ "${AGENTBOX_TAB0_BRIDGE_SUPERVISED:-0}" = "1" ] && [ -z "${BRIDGE_TOKEN:-}" export BRIDGE_TOKEN fi +# Custody W1 (ADR-2122): pick the supervisor config. Flag off: today's +# /etc/supervisord.conf, unchanged. Flag on: deliver every role's secrets into +# /run/secrets// (0500 dir, 0400 files, owned by the role), unset the +# classified variables from this launcher's environment so supervisord never +# inherits them, then run /etc/supervisord.roles.conf, which differs from +# today's only in the role programs' user= and environment= lines. +_AB_SUPERVISORD_CONF=/etc/supervisord.conf +if [ "$AGENTBOX_ROLE_ISOLATION" = 1 ]; then + AB_RC_FAILURES=0 + ab_role_secrets_deliver /etc/agentbox/role-secrets.tsv || true + { + [ "$_AB_SECRETS_MOUNT_STATE" = ok ] || echo "degraded:secrets-mount" + [ "${AB_RC_FAILURES:-0}" = 0 ] && echo "ok:secrets-delivery" || echo "degraded:secrets-delivery" + } >>/run/secrets/role-isolation.state 2>/dev/null \ + || echo "[security] WARN: could not record the delivery state in /run/secrets/role-isolation.state" >&2 + _AB_SUPERVISORD_CONF="$(ab_supervisord_conf_pick 1)" + echo "[security] role_isolation: supervisord config ${_AB_SUPERVISORD_CONF}" +fi echo "[5b/8] Starting supervisord..." -exec supervisord -c /etc/supervisord.conf -n +# Custody W2: nothing between here and exec may export a ROLE variable. +_ab_role_env_scrub "$AGENTBOX_ROLE_ISOLATION" +exec supervisord -c "$_AB_SUPERVISORD_CONF" -n fi # end STAGE_B_MODE=0 block — Stage A exits via exec above @@ -915,7 +1240,20 @@ export SHARED_PROJECTS_ROOT="${SHARED_PROJECTS_ROOT:-/projects}" # ADR-2028: normally inherited from PID 1 (Stage A resolved it before exec # supervisord); this only fires if Stage B is ever invoked standalone. [ -n "${AGENTBOX_VAULT_ENABLED:-}" ] || _ab_vault_resolve -_ab_cargo_bin_on_path +# One-shot (custody W0): a replay via `supervisorctl start bootstrap` is a no-op. +# PATH is already store-only (sanitised above the stage dispatch). Custody W1: +# AGENTBOX_ROLE_ISOLATION is PID 1's effective value (inherited through +# supervisord), so Stage B picks the same guard dir Stage A armed. +if [ "${AGENTBOX_ROLE_ISOLATION:-0}" = 1 ] && declare -F ab_root_state_dir_pick >/dev/null; then + AB_ROOT_STATE_DIR="$(ab_root_state_dir_pick 1)" +fi +_ab_b_claim_rc=0 +_ab_stage_b_claim "$AB_ROOT_STATE_DIR" || _ab_b_claim_rc=$? +case "$_ab_b_claim_rc" in + 0) echo "[bootstrap] Stage B claimed ${AB_ROOT_STATE_DIR}/stage-b.claimed (one-shot)" ;; + 1) echo "[bootstrap] Stage B already ran in this container start; no-op (restart the container to re-run)"; exit 0 ;; + *) echo "[bootstrap] REFUSED: ${AB_ROOT_STATE_DIR} is missing or not root-owned 0700; Stage B will not run" >&2; exit 1 ;; +esac # --------------------------------------------------------------------------- # Phase 6 — Service closure probes (PRD-002 §9 Phase 1) @@ -2971,8 +3309,15 @@ echo "[8/8] Publishing environment hints..." RUNTIME_ENV_FILE=/run/agentbox/runtime-env.sh RUNTIME_ENV_DURABLE=/home/devuser/workspace/.agentbox-runtime-env.sh mkdir -p "$(dirname "$RUNTIME_ENV_FILE")" 2>/dev/null || true +# Custody W0: under [security].role_isolation devuser's docker CLI talks to the +# GET-only proxy (ps/logs/inspect); exec/run move to the host shell (Q1). +_ROLE_ISO_EXPORTS="" +if [ "${AGENTBOX_ROLE_ISOLATION:-0}" = 1 ]; then + _ROLE_ISO_EXPORTS='export DOCKER_HOST="unix:///run/docker-ro.sock"' +fi cat > "$RUNTIME_ENV_FILE" </dev/null || true diff --git a/config/hooks/lib/operator-key.cjs b/config/hooks/lib/operator-key.cjs new file mode 100644 index 000000000..4ab506cb7 --- /dev/null +++ b/config/hooks/lib/operator-key.cjs @@ -0,0 +1,72 @@ +'use strict'; + +/** + * operator-key — the operator (sovereign) signing key for the live-mirror hook, + * the nostr-gateway and nostr-send (custody X-1 step 1, W2, bypass 3). + * + * Under [security].role_isolation the key comes through the shared ROLE-secret + * loader (management-api/lib/role-secret.js): `_FILE` only; a bare + * variable is reported as ROLE-ISOLATION-LEAK and ignored. The entrypoint + * delivers AGENTBOX_PRIVKEY_HEX_FILE / OPERATOR_NOSTR_PRIVKEY_FILE, and + * identity.env exports AGENTBOX_BRIDGE_SK_FILE. + * + * With the flag off the read is the pre-W2 one, unchanged: the first non-empty + * of the three variables. The mirror child key and the gateway identity derive + * from this key and must not move, and AGENTBOX_BRIDGE_SK_FILE is exported with + * the flag off too, so a file-first read there would change which key wins. + */ + +const path = require('path'); + +const OPERATOR_KEY_VARS = Object.freeze(['AGENTBOX_PRIVKEY_HEX', 'AGENTBOX_BRIDGE_SK', 'OPERATOR_NOSTR_PRIVKEY']); +const LOADER_CANDIDATES = Object.freeze([ + path.resolve(__dirname, '..', '..', '..', 'management-api', 'lib', 'role-secret.js'), + '/opt/agentbox/management-api/lib/role-secret.js', +]); + +let cached; +function roleSecretLoader(candidates = LOADER_CANDIDATES) { + if (cached !== undefined && candidates === LOADER_CANDIDATES) return cached; + let found = null; + for (const c of candidates) { + try { found = require(c); break; } catch { /* next */ } + } + if (candidates === LOADER_CANDIDATES) cached = found; + return found; +} + +function envFirst(env, keys) { + for (const k of keys) { + const v = env[k]; + if (v && String(v).trim()) return String(v).trim(); + } + return ''; +} + +/** + * The operator key as hex ('' when none). Never throws, never logs a value. + * + * @param {object} [opts] + * @param {Function} [opts.log] - receives one line per problem (names/paths only) + * @param {object} [opts.env=process.env] + * @param {string[]} [opts.loaderCandidates] - test seam + * @returns {string} + */ +function operatorKeyHex(opts = {}) { + const env = opts.env || process.env; + const log = opts.log || (() => {}); + if (String(env.AGENTBOX_ROLE_ISOLATION || '') !== '1') return envFirst(env, OPERATOR_KEY_VARS); + const rs = roleSecretLoader(opts.loaderCandidates || LOADER_CANDIDATES); + if (!rs) { + log('role-secret loader unavailable: no operator key under role isolation'); + return ''; + } + try { + return rs.readRoleSecretFirst(OPERATOR_KEY_VARS, { env, log }).value; + } catch (err) { + log(err.message); + return ''; + } +} + +module.exports = { operatorKeyHex, OPERATOR_KEY_VARS }; diff --git a/config/hooks/nostr-live-mirror.cjs b/config/hooks/nostr-live-mirror.cjs index c6ed2db2a..18e884938 100755 --- a/config/hooks/nostr-live-mirror.cjs +++ b/config/hooks/nostr-live-mirror.cjs @@ -44,6 +44,7 @@ const crypto = require('crypto'); // ADR-2026 (closeout 2026-09-05): the shared content-egress policy — the same // decision table and redaction contract the Rust digest path implements. const egress = require('./lib/egress-policy.cjs'); +const { operatorKeyHex } = require('./lib/operator-key.cjs'); const fs = require('fs'); const path = require('path'); @@ -210,7 +211,7 @@ let _childCache; function deriveChildKey() { if (_childCache !== undefined) return _childCache; if (String(process.env.AGENTBOX_MIRROR_CHILD || '').trim() === '0') { _childCache = null; return null; } - const hex = envFirst('AGENTBOX_PRIVKEY_HEX', 'AGENTBOX_BRIDGE_SK', 'OPERATOR_NOSTR_PRIVKEY'); + const hex = operatorKeyHex({ log }); if (!/^[0-9a-f]{64}$/i.test(hex)) { _childCache = null; return null; } const tag = envFirst('AGENTBOX_MIRROR_KEY_TAG') || 'agentbox-mirror-v1'; try { @@ -222,7 +223,7 @@ function deriveChildKey() { /** Sender identity sealed inside the gift wrap. */ function senderSecretKey(tools) { - const hex = envFirst('AGENTBOX_PRIVKEY_HEX', 'AGENTBOX_BRIDGE_SK', 'OPERATOR_NOSTR_PRIVKEY'); + const hex = operatorKeyHex({ log }); if (/^[0-9a-f]{64}$/i.test(hex)) { try { return Uint8Array.from(Buffer.from(hex, 'hex')); } catch { /* fall through */ } } @@ -524,6 +525,9 @@ async function main() { // hook is invoked directly, so a `require()` in a test never spawns the publish // path or the process-exit guard. module.exports = { + // Custody W2: the operator-key read, re-exported so a test can assert the hook + // uses the shared one. + operatorKeyHex, composeBody, mintActivityUrn, activityScopePubkey, diff --git a/config/hooks/project-tracking-publish.cjs b/config/hooks/project-tracking-publish.cjs index 249b15ba9..754e4d584 100755 --- a/config/hooks/project-tracking-publish.cjs +++ b/config/hooks/project-tracking-publish.cjs @@ -58,7 +58,10 @@ function envFirst(...keys) { /** True only when every input the `track` subcommand requires is set. */ function bridgeConfigured() { - const haveSk = !!(envFirst('AGENTBOX_BRIDGE_SK') || envFirst('AGENTBOX_BRIDGE_SK_FILE')); + // Custody W2: under [security].role_isolation only the key file counts; the + // bare AGENTBOX_BRIDGE_SK is a leak there (same rule as the Rust reader). + const isolated = String(process.env.AGENTBOX_ROLE_ISOLATION || '') === '1'; + const haveSk = !!((!isolated && envFirst('AGENTBOX_BRIDGE_SK')) || envFirst('AGENTBOX_BRIDGE_SK_FILE')); return haveSk && !!envFirst('AGENTBOX_BRIDGE_RECIPIENT_PUBKEY') && !!envFirst('AGENTBOX_POD_ROOT') diff --git a/config/instructions/workspace.md b/config/instructions/workspace.md index 4cba00d5f..a5a646974 100644 --- a/config/instructions/workspace.md +++ b/config/instructions/workspace.md @@ -41,6 +41,8 @@ watch = `aoe` TUI in tmux tab 8, `tmux attach -t ` from a termina The host Docker socket makes builds launched in here *look* like they work, but bind paths resolve against the **host** filesystem, so they silently bake stale code. Edit sources here; launch the host project's builds from the host shell (its tmux tab), and monitor from here with `tmux capture-pane` and `docker exec`. Do not SSH to the host. +Under `[security].role_isolation = true` (ADR-2122; off by default, staged), `docker exec` is no longer a monitoring path from in here. `DOCKER_HOST` points at the GET-only proxy `/run/docker-ro.sock`: `docker ps`, `logs` and `inspect` work, while `exec`, `run` and `cp` get 403. Monitor with `tmux capture-pane` and `docker logs`, and run anything that needs `exec` from the host shell. Under the flag, role secrets sit in the root-owned `/run/secrets//` and are unreadable to devuser. Signing goes through the identity port, `/run/secrets/ab-identity-port/identity.sock` (`nostr-pod-bridge sign-request `). + ## Claude Cowork `cowork start|stop|status|restart|logs` — Claude Desktop Cowork on VNC :1. `claude-desktop --devtools|--doctor`. diff --git a/config/lib/role-custody.sh b/config/lib/role-custody.sh new file mode 100644 index 000000000..47608d198 --- /dev/null +++ b/config/lib/role-custody.sh @@ -0,0 +1,621 @@ +# shellcheck shell=bash +# config/lib/role-custody.sh — /run/secrets delivery for per-role service +# accounts (ADR-2122, custody X-1 step 1, W1). Sourced by +# config/entrypoint-unified.sh; definitions only, nothing runs on source. +# +# Every function here is reached only when [security].role_isolation = true, +# except the two pickers, which return today's values for any other input. So +# with the flag off the boot is what it was before this file existed. +# +# Contract (the entrypoint relies on all of it): +# * fail-open: no function returns non-zero to a `set -e` caller for a +# delivery problem; failures are counted in AB_RC_FAILURES and logged with a +# grep-able [security] marker. A missing secret makes its role program fail +# closed under supervisord, which is where it belongs, not the boot. +# * no secret VALUE is ever printed, logged, or passed on argv. File sources go +# through `cat `; environment values through the `printf` builtin +# inside a subshell, which forks but never execs, so no argv carries them. +# * runs before supervisord, so no devuser process exists yet to race a +# check-then-copy. Symlinked, non-regular or oversized sources are refused +# anyway, because the workspace bind persists across boots. +# +# Test seams: AB_RC_CHOWN (default chown) is the only call that needs root; +# tests/config/role-secrets-delivery.test.sh stubs it with a ledger. The at-rest +# migrate/revert (W2b, below) adds AB_RC_STAT (default stat; the stub reports +# the ledger's owner) and AB_RC_ROOT (a prefix on every registry path, so the +# real plan runs against a scratch tree); tests/config/role-custody-migrate.test.sh. + +AB_RC_MAX_SECRET_BYTES=65536 + +# ab_root_state_dir_pick [secrets-root=/run/secrets] +# Where W0's Stage-B one-shot guard lives. With the flag on AND /run/secrets a +# root-owned mount point, inside it (devuser cannot rename a mount point or +# enter a root 0711 dir's unlisted entries). Otherwise the sticky /tmp path W0 +# chose, unchanged. Flag off always gives /tmp: the flag-off boot chowns +# /run/secrets to devuser, so a guard there would be devuser's to remove. +ab_root_state_dir_pick() { + local root="${2:-/run/secrets}" + if [ "${1:-0}" = 1 ] && ab_secrets_mount_ok "$root"; then + printf '%s\n' "${root}/.root-guard" + else + printf '%s\n' /tmp/.agentbox-root + fi +} + +# ab_supervisord_conf_pick [today] [isolated] +# The config `exec supervisord -c` gets. Only the exact value 1 with a readable +# isolated config selects it; anything else is today's config, byte for byte. +ab_supervisord_conf_pick() { + local today="${2:-/etc/supervisord.conf}" isolated="${3:-/etc/supervisord.roles.conf}" + if [ "${1:-0}" = 1 ] && [ -r "$isolated" ] && [ ! -L "$isolated" ]; then + printf '%s\n' "$isolated" + else + printf '%s\n' "$today" + fi +} + +# ab_role_isolation_ready [isolated-conf] [plan] → 0 when the image can honour +# the flag. An image that lacks either artefact boots exactly as today. +ab_role_isolation_ready() { + local conf="${1:-/etc/supervisord.roles.conf}" plan="${2:-/etc/agentbox/role-secrets.tsv}" + [ -r "$conf" ] && [ -r "$plan" ] && grep -q '^role ' "$plan" 2>/dev/null +} + +# _ab_rc_is_mountpoint → 0 when sits on a different device from its +# parent. Plain coreutils (no util-linux `mountpoint` needed on root's PATH). +_ab_rc_is_mountpoint() { + local d p + d="$(stat -c %d -- "$1" 2>/dev/null)" || return 1 + p="$(stat -c %d -- "$1/.." 2>/dev/null)" || return 1 + [ "$d" != "$p" ] +} + +# ab_secrets_mount_ok [owner-uid=0] → 0 when is a real directory (not +# a symlink), a mount point, owned by , and not group/other-writable. +ab_secrets_mount_ok() { + local dir="$1" uid="${2:-0}" st mode + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + _ab_rc_is_mountpoint "$dir" || return 1 + st="$(stat -c '%u %a' -- "$dir" 2>/dev/null)" || return 1 + [ "${st%% *}" = "$uid" ] || return 1 + mode="${st##* }" + case "${mode: -2}" in *[2367]*) return 1 ;; esac + return 0 +} + +# ab_secrets_root_prepare → make root:root 0711 (traverse-only for +# everyone else). Prints a ROLE-ISOLATION-DEGRADED marker and returns 1 when it +# is not a mount point: then devuser, owner of /run, could rename it after boot. +ab_secrets_root_prepare() { + local root="$1" chown_cmd="${AB_RC_CHOWN:-chown}" + if [ -L "$root" ]; then + echo "[security] ROLE-ISOLATION-DEGRADED secrets-mount: ${root} is a symlink; refusing to use it" >&2 + return 1 + fi + mkdir -p -- "$root" 2>/dev/null || true + "$chown_cmd" 0:0 "$root" 2>/dev/null || true + chmod 0711 "$root" 2>/dev/null || true + if ! _ab_rc_is_mountpoint "$root"; then + echo "[security] ROLE-ISOLATION-DEGRADED secrets-mount: ${root} is not its own mount (compose tmpfs missing; regenerate docker-compose.yml). devuser owns /run and can rename it." >&2 + return 1 + fi + return 0 +} + +# _ab_rc_place — finish one secret file: 0400, owned by +# the role, renamed over . Returns non-zero on any failure (tmp removed). +_ab_rc_place() { + local tmp="$1" dest="$2" uid="$3" gid="$4" chown_cmd="${AB_RC_CHOWN:-chown}" + if chmod 0400 "$tmp" && "$chown_cmd" "${uid}:${gid}" "$tmp" && mv -f -- "$tmp" "$dest"; then + return 0 + fi + rm -f -- "$tmp" 2>/dev/null || true + return 1 +} + +# _ab_rc_dir : — a `dir` plan row. Refuses a symlink at +# (moved aside) and any path inside the secrets root; the parent must exist. +_ab_rc_dir() { + local path="$1" ids="$2" mode="$3" chown_cmd="${AB_RC_CHOWN:-chown}" + case "$path" in + /*/*) ;; + *) echo "[security] ROLE-ISOLATION-ERROR: dir row ${path:-?} is not below a top-level directory; skipped" >&2; return 1 ;; + esac + case "$path" in *..*|*//*|*/) + echo "[security] ROLE-ISOLATION-ERROR: dir row ${path} is not a plain path; skipped" >&2; return 1 ;; + esac + case "$ids" in [0-9]*:[0-9]*) ;; *) + echo "[security] ROLE-ISOLATION-ERROR: dir row ${path} has owner ${ids:-?}, not uid:gid; skipped" >&2; return 1 ;; + esac + case "${ids%%:*}${ids#*:}" in *[!0-9]*) + echo "[security] ROLE-ISOLATION-ERROR: dir row ${path} has owner ${ids}, not uid:gid; skipped" >&2; return 1 ;; + esac + case "$mode" in *[2367]|*[!0-7]*) mode=bad ;; esac + case "$mode" in [0-7][0-7][0-7]|[0-7][0-7][0-7][0-7]) ;; *) + echo "[security] ROLE-ISOLATION-ERROR: dir row ${path} has mode ${3:-?} (3-4 octal digits, not world-writable); skipped" >&2; return 1 ;; + esac + if [ -L "$path" ] || { [ -e "$path" ] && [ ! -d "$path" ]; }; then + mv -T -- "$path" "${path}.squatted.$$" 2>/dev/null || rm -f -- "$path" 2>/dev/null || true + fi + if [ ! -d "${path%/*}" ]; then + echo "[security] ROLE-ISOLATION-ERROR: dir row ${path}: parent ${path%/*} is absent; not created" >&2 + return 1 + fi + if { [ -d "$path" ] || mkdir -- "$path"; } && "$chown_cmd" "$ids" "$path" && chmod "$mode" "$path"; then + return 0 + fi + echo "[security] ROLE-ISOLATION-ERROR: cannot prepare ${path} (${ids} ${mode})" >&2 + return 1 +} + +# AB_RC_MAX_SEED_BYTES caps one seeded state file (a chain's blocks.dat grows). +AB_RC_MAX_SEED_BYTES=268435456 + +# _ab_rc_seed — custody W4: a role +# program's state leaves the workspace bind for a volume dir it owns. Runs only +# while holds no regular file, so it copies once and never overwrites what +# the role has written since. a directory: its top-level regular files; +# a file: that file, named . Symlinks and non-regular entries are +# skipped, never followed. Each copy is verified (size and sha256 of both +# sides; state, not secrets, so the hash is not a disclosure), then owned by +# , 0640 when the dir's group may read (the mirror) else 0600. The +# source is never touched. Names only in the log. +_ab_rc_seed() { + local dir="$1" from="$2" to="$3" ids="$4" dmode="$5" f n=0 dest tmp size fmode=0600 chown_cmd="${AB_RC_CHOWN:-chown}" + local -a srcs=() + [ -d "$dir" ] && [ ! -L "$dir" ] || { echo "[security] ROLE-ISOLATION-ERROR: seed: ${dir} is not a directory" >&2; return 1; } + for f in "$dir"/* "$dir"/.[!.]*; do + if [ -f "$f" ] && [ ! -L "$f" ]; then return 0; fi # already holds state: never re-seed + done + if [ -L "$from" ]; then + echo "[security] ROLE-ISOLATION-ERROR: seed: ${from} is a symlink; not followed" >&2; return 1 + elif [ -d "$from" ]; then + for f in "$from"/* "$from"/.[!.]*; do [ -f "$f" ] && [ ! -L "$f" ] && srcs+=("$f"); done + to=- + elif [ -f "$from" ]; then + srcs=("$from") + else + return 0 # nothing to seed (a chain that never ran): the role starts fresh + fi + case "${dmode: -2:1}" in [4-7]) fmode=0640 ;; *) fmode=0600 ;; esac + for f in "${srcs[@]}"; do + if [ "$to" != - ]; then dest="${dir}/${to}"; else dest="${dir}/${f##*/}"; fi + size="$(stat -c %s -- "$f" 2>/dev/null || echo -1)" + if [ "$size" -lt 0 ] || [ "$size" -gt "$AB_RC_MAX_SEED_BYTES" ]; then + echo "[security] ROLE-ISOLATION-ERROR: seed: ${f} is ${size} bytes; skipped" >&2; return 1 + fi + tmp="${dir}/.${dest##*/}.seed.$$" + if ( umask 077; cat -- "$f" >"$tmp" ) 2>/dev/null && sync -- "$tmp" 2>/dev/null \ + && [ "$(stat -c %s -- "$tmp" 2>/dev/null)" = "$size" ] \ + && [ "$(sha256sum <"$f")" = "$(sha256sum <"$tmp")" ] \ + && chmod "$fmode" "$tmp" && "$chown_cmd" "$ids" "$tmp" && mv -T -- "$tmp" "$dest"; then + n=$((n + 1)) + else + rm -f -- "$tmp" 2>/dev/null || true + echo "[security] ROLE-ISOLATION-ERROR: seed: copying ${f} into ${dir} failed verification" >&2; return 1 + fi + done + sync -- "$dir" 2>/dev/null || true + [ "$n" -gt 0 ] && echo "[security] role-isolation: seeded ${dir} with ${n} file(s) from ${from} (source left in place)" + return 0 +} + +# ab_role_secrets_deliver [secrets-root-override] +# Execute the plan written by `agentbox-manifest role-accounts isolate`: +# root the secrets root (the override, if given, wins) +# role / 0500 and //home 0700 +# file copy to //, 0400 +# env write $VAR to //, 0400; unset VAR +# sockdir / 0750 owned uid:gid (the identity +# port's socket dir: inside the root mount, so +# devuser, owner of /run, cannot rename it) +# dir : create or re-own outside +# seed copy state into that dir once, while it holds no +# regular file (_ab_rc_seed; custody W4) +# atrest / atrestdir the at-rest registry; skipped here (ab_custody_migrate) +# Every `env` variable is unset whether or not it had a value, so none of them +# reaches supervisord (PID 1) or any child. Sets AB_RC_DELIVERED, AB_RC_FAILURES. +ab_role_secrets_deliver() { + local plan="$1" override="${2:-}" root="" kind name a b uid gid dir dest tmp size + local chown_cmd="${AB_RC_CHOWN:-chown}" + local -A role_uid=() role_gid=() dir_ids=() dir_mode=() + AB_RC_DELIVERED=0 + AB_RC_FAILURES=0 + if [ ! -r "$plan" ]; then + echo "[security] ROLE-ISOLATION-ERROR: delivery plan ${plan} is missing; no role secret delivered" >&2 + AB_RC_FAILURES=1 + return 0 + fi + while IFS=$'\t' read -r kind name a b || [ -n "$kind" ]; do + case "$kind" in + ''|'#'*|atrest|atrestdir) continue ;; # the at-rest registry: ab_custody_migrate/revert + root) + root="${override:-$name}" + continue ;; + dir) + if _ab_rc_dir "$name" "$a" "$b"; then dir_ids[$name]="$a"; dir_mode[$name]="$b" + else AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); fi + continue ;; + seed) + if [ -z "${dir_ids[$name]:-}" ]; then + echo "[security] ROLE-ISOLATION-ERROR: seed row for ${name} has no prepared dir row before it; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue + fi + _ab_rc_seed "$name" "$a" "$b" "${dir_ids[$name]}" "${dir_mode[$name]}" || AB_RC_FAILURES=$((AB_RC_FAILURES + 1)) + continue ;; + esac + if [ -z "$root" ]; then + echo "[security] ROLE-ISOLATION-ERROR: plan ${plan} has no root row before its first ${kind} row" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); return 0 + fi + case "$name" in + ab-*) ;; + *) echo "[security] ROLE-ISOLATION-ERROR: plan row names ${name:-nothing}, not a role; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue ;; + esac + case "$name$a" in */*|*..*) + echo "[security] ROLE-ISOLATION-ERROR: plan row for ${name} has a path separator; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue ;; + esac + dir="${root}/${name}" + case "$kind" in + role) + uid="$a"; gid="$b" + case "$uid$gid" in ''|*[!0-9]*) + echo "[security] ROLE-ISOLATION-ERROR: role ${name} has a non-numeric uid/gid; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue ;; + esac + if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then + mv -T -- "$dir" "${dir}.squatted.$$" 2>/dev/null || rm -f -- "$dir" 2>/dev/null || true + fi + # 0700 while filling (a non-root test runner needs write; root ignores it). + if ! mkdir -p -- "$dir/home" || ! chmod 0700 "$dir"; then + echo "[security] ROLE-ISOLATION-ERROR: cannot create ${dir}" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue + fi + chmod 0700 "$dir/home" 2>/dev/null || true + "$chown_cmd" "${uid}:${gid}" "$dir/home" 2>/dev/null \ + || { echo "[security] ROLE-ISOLATION-ERROR: cannot chown ${dir}/home to ${uid}" >&2; AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); } + role_uid[$name]="$uid"; role_gid[$name]="$gid" + ;; + sockdir) + case "$a$b" in ''|*[!0-9]*) + echo "[security] ROLE-ISOLATION-ERROR: sockdir ${name} has a non-numeric uid/gid; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue ;; + esac + if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then + mv -T -- "$dir" "${dir}.squatted.$$" 2>/dev/null || rm -f -- "$dir" 2>/dev/null || true + fi + if mkdir -p -- "$dir" && chmod 0750 "$dir" && "$chown_cmd" "${a}:${b}" "$dir"; then : + else + echo "[security] ROLE-ISOLATION-ERROR: cannot prepare socket dir ${dir} (${a}:${b} 0750)" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)) + fi + ;; + file|env) + if [ -z "${role_uid[$name]:-}" ]; then + echo "[security] ROLE-ISOLATION-ERROR: ${kind} row for ${name} precedes its role row; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue + fi + dest="${dir}/${a}" + tmp="${dir}/.${a}.tmp.$$" + rm -f -- "$tmp" 2>/dev/null || true + if [ "$kind" = file ]; then + if [ -n "${AB_RC_REFUSED[$b]:-}" ]; then + echo "[security] ROLE-ISOLATION-ERROR: ${name}/${a}: source ${b} was refused by the custody migrate step (${AB_RC_REFUSED[$b]}); not delivered" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue + fi + if [ -L "$b" ] || [ ! -f "$b" ]; then + if [ -e "$b" ] || [ -L "$b" ]; then + echo "[security] ROLE-ISOLATION-ERROR: ${name}/${a}: source ${b} is a symlink or not a regular file; refused" >&2 + else + echo "[security] role-isolation: ${name}/${a}: source ${b} is absent; the role program will fail closed" >&2 + fi + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue + fi + size="$(stat -c %s -- "$b" 2>/dev/null || echo 0)" + if [ "$size" -gt "$AB_RC_MAX_SECRET_BYTES" ]; then + echo "[security] ROLE-ISOLATION-ERROR: ${name}/${a}: source ${b} is ${size} bytes (> ${AB_RC_MAX_SECRET_BYTES}); refused" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue + fi + if ! ( umask 077; cat -- "$b" >"$tmp" ) 2>/dev/null || ! _ab_rc_place "$tmp" "$dest" "${role_uid[$name]}" "${role_gid[$name]}"; then + rm -f -- "$tmp" 2>/dev/null || true + echo "[security] ROLE-ISOLATION-ERROR: ${name}/${a}: copy from ${b} failed" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue + fi + else + case "$b" in ''|[!A-Z_]*|*[!A-Z0-9_]*) + echo "[security] ROLE-ISOLATION-ERROR: ${name}/${a}: ${b:-nothing} is not an environment name; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); continue ;; + esac + if [ -z "${!b:-}" ]; then + unset "$b" + continue + fi + if ! ( umask 077; printf '%s' "${!b}" >"$tmp" ) 2>/dev/null || ! _ab_rc_place "$tmp" "$dest" "${role_uid[$name]}" "${role_gid[$name]}"; then + rm -f -- "$tmp" 2>/dev/null || true + echo "[security] ROLE-ISOLATION-ERROR: ${name}/${a}: could not write \$${b}; unset anyway" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)) + unset "$b" + continue + fi + unset "$b" + fi + AB_RC_DELIVERED=$((AB_RC_DELIVERED + 1)) + ;; + *) + echo "[security] ROLE-ISOLATION-ERROR: unknown plan row kind ${kind}; skipped" >&2 + AB_RC_FAILURES=$((AB_RC_FAILURES + 1)) ;; + esac + done <"$plan" + # Seal each role dir last: 0500 owned by the role (read and traverse only). + for name in "${!role_uid[@]}"; do + chmod 0500 "${root}/${name}" 2>/dev/null || true + "$chown_cmd" "${role_uid[$name]}:${role_gid[$name]}" "${root}/${name}" 2>/dev/null \ + || { echo "[security] ROLE-ISOLATION-ERROR: cannot chown ${root}/${name}" >&2; AB_RC_FAILURES=$((AB_RC_FAILURES + 1)); } + done + echo "[security] role-isolation: delivered ${AB_RC_DELIVERED} secret(s) into ${#role_uid[@]} role dir(s) under ${root:-?}; ${AB_RC_FAILURES} problem(s)" + return 0 +} + +# ── At-rest custody: migrate (flag on) and revert (flag off) ────────────────── +# Custody design 3.2-3.4 (W2b). The registry is the `atrest` and `atrestdir` +# rows of the delivery plan, derived by `agentbox-manifest role-accounts +# isolate` from config/role-accounts.json (each file secret's resolved source, +# each role's `at_rest`, and `at_rest_dirs`); nothing here lists a secret. +# +# atrestdir root:root under the flag +# atrest : 0400 under the flag +# +# migrate, per atrest row: when is absent and is a regular file, +# copy it (umask 077, temp file in the same dir, fsync, size and byte compare, +# rename, fsync the dir). The legacy copy is never touched or deleted (Q5). A +# that is a symlink, not a regular file, hard-linked, empty or oversized +# is REFUSED: not re-owned, not delivered, counted. So is a that already +# existed on the first migration and differs from its legacy copy (something +# other than this step put it there). Then the copy goes to its role, 0400, and +# each atrestdir to root:root. +# +# Pre-flag owner and mode go to a modes record (/ +# .role-custody.modes, root 0600) the first time a path is seen, and only then; +# revert hands each registry path back to devuser (or to root, when root owned +# it before) with that mode. The record is +# trusted only when root owns it and it is a single-link regular file: devuser +# owns the volumes with the flag off and could otherwise forge it. No record +# means nothing was ever migrated, and revert changes nothing. +# +# Every path, legacy copy and refusal is written, by NAME only, to the migrated +# record (default /run/secrets/role-isolation.migrated, 0644: it holds no value +# and the rehearsal receipt lists it). Nothing here opens a secret except cat +# into the temp copy and the od compare, neither of which prints content. +# +# Both functions are fail-open (rc 0) and idempotent: an owner or mode already +# right is not set again, so a second run leaves every inode's ctime alone and +# a stat of the registry hashes the same before and after. + +AB_RC_DEVUSER_IDS=1000:1000 +declare -gA AB_RC_REFUSED=() + +# _ab_rc_stat -> "uid gid mode nlink size type" (empty when absent). +# Never follows a symlink (stat without -L). +_ab_rc_stat() { "${AB_RC_STAT:-stat}" -c '%u %g %a %h %s %F' -- "$1" 2>/dev/null; } + +# _ab_rc_same -> 0 when the two files are byte-identical. Compared in +# memory as od hex (coreutils only: the root boot PATH is store-only and need not +# carry diffutils' cmp); nothing is printed. Callers cap sizes at +# AB_RC_MAX_SECRET_BYTES, so each string is at most ~200 KiB. +_ab_rc_same() { + local x y + x="$(od -An -v -tx1 -- "$1" 2>/dev/null)" || return 1 + y="$(od -An -v -tx1 -- "$2" 2>/dev/null)" || return 1 + [ "$x" = "$y" ] +} + +# _ab_rc_set : chown/chmod only what differs. A symlink is +# never passed to chown (it would follow it). +_ab_rc_set() { + local p="$1" ids="$2" mode="$3" st uid gid cur chown_cmd="${AB_RC_CHOWN:-chown}" + [ -L "$p" ] && return 1 + st="$(_ab_rc_stat "$p")" || return 1 + [ -n "$st" ] || return 1 + read -r uid gid cur _ <<<"$st" + if [ "${uid}:${gid}" != "$ids" ]; then "$chown_cmd" "$ids" "$p" || return 1; fi + if [ "$((8#$cur))" != "$((8#$mode))" ]; then chmod "$mode" "$p" || return 1; fi + return 0 +} + +# _ab_rc_modes_trusted -> 0 when root owns it, it is a regular file with +# one link and nobody else can write it. +_ab_rc_modes_trusted() { + local st uid mode links + [ -f "$1" ] && [ ! -L "$1" ] || return 1 + st="$(_ab_rc_stat "$1")" || return 1 + read -r uid _ mode links _ <<<"$st" + [ "$uid" = 0 ] && [ "$links" = 1 ] || return 1 + case "${mode: -2}" in 00) return 0 ;; esac + return 1 +} + +# _ab_rc_plan_rows : print "kindabc" for role, atrest and +# atrestdir rows; the parsers below share it. +_ab_rc_plan_rows() { + awk -F'\t' '$1=="role"||$1=="atrest"||$1=="atrestdir" {print}' "$1" 2>/dev/null +} + +# ab_custody_migrate [migrated-record=/run/secrets/role-isolation.migrated] +# Sets AB_RC_MIGRATED (copies made this run), AB_RC_MIG_FAILURES, AB_RC_REFUSED. +ab_custody_migrate() { + local plan="$1" record="${2:-/run/secrets/role-isolation.migrated}" + local R="${AB_RC_ROOT:-}" kind a b c p l st uid gid mode links size dir modes="" tmp rec seen + local -A role_uid=() is_dir=() recorded=() + AB_RC_MIGRATED=0; AB_RC_MIG_FAILURES=0; AB_RC_REFUSED=() + if [ ! -r "$plan" ]; then + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: plan ${plan} is missing; nothing migrated" >&2 + AB_RC_MIG_FAILURES=1; return 0 + fi + rec="$(mktemp "$R${record%/*}/.role-isolation.migrated.XXXXXX" 2>/dev/null)" || rec=/dev/null + printf '# role-isolation custody migrate (design 3.2). Paths only; no value, no hash.\n# status\tpath\tlegacy-or-reason\n' >"$rec" + while IFS=$'\t' read -r kind a b c; do + case "$kind" in + role) role_uid[$a]="$b" ;; + atrestdir) is_dir[$a]="$b"; [ -n "$modes" ] || modes="${a}/.role-custody.modes" ;; + esac + done < <(_ab_rc_plan_rows "$plan") + if [ -z "$modes" ]; then + echo "[security] role-isolation: custody migrate: the plan has no at-rest registry; nothing to migrate" + rm -f -- "$rec" 2>/dev/null; return 0 + fi + # Earlier records win: only a path never seen before gets its state recorded. + if _ab_rc_modes_trusted "$R$modes"; then + while IFS=$'\t' read -r _ p _; do [ -n "$p" ] && recorded[$p]=1; done <"$R$modes" + elif [ -e "$R$modes" ] || [ -L "$R$modes" ]; then + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: ${modes} is not a root-owned 0600 single-link file; moved aside, recording afresh" >&2 + mv -T -- "$R$modes" "$R$modes.untrusted.$$" 2>/dev/null || true + AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)) + fi + _ab_rc_note() { # : first sighting only + [ -n "${recorded[$1]:-}" ] && return 0 + ( umask 077; printf '%s\t%s\t%s:%s\t%s\n' "${2}" "$1" "$3" "$4" "$5" >>"$R$modes" ) 2>/dev/null \ + || { echo "[security] ROLE-ISOLATION-ERROR: custody migrate: cannot record the pre-flag state of $1" >&2; return 1; } + recorded[$1]=1 + } + # Directories first: a symlink or non-directory at a registry dir stops its rows. + for dir in "${!is_dir[@]}"; do + if [ -L "$R$dir" ] || [ ! -d "$R$dir" ]; then + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: ${dir} is not a real directory; its at-rest copies are left alone" >&2 + is_dir[$dir]=; AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); continue + fi + read -r uid gid mode _ <<<"$(_ab_rc_stat "$R$dir")" + _ab_rc_note "$dir" d "$uid" "$gid" "$mode" || AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)) + done + # The record itself: root 0600 (created by this root process; tests stub chown). + if [ -f "$R$modes" ]; then + _ab_rc_set "$R$modes" 0:0 600 || true + fi + while IFS=$'\t' read -r kind a b c; do + [ "$kind" = atrest ] || continue + p="$b"; l="$c" + if [ -z "${role_uid[$a]:-}" ]; then + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: ${p} names role ${a:-?}, which has no role row; skipped" >&2 + AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); continue + fi + if [ -z "${is_dir[${p%/*}]:-}" ]; then + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: ${p} is not inside a usable at-rest dir; left alone" >&2 + AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); continue + fi + seen="${recorded[$p]:-}" + if [ ! -e "$R$p" ] && [ ! -L "$R$p" ] && [ "$l" != - ] && [ -f "$R$l" ] && [ ! -L "$R$l" ]; then + size="$(stat -c %s -- "$R$l" 2>/dev/null || echo 0)" + if [ "$size" -gt "$AB_RC_MAX_SECRET_BYTES" ] || [ "$size" -eq 0 ]; then + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: legacy ${l} is ${size} bytes; not copied" >&2 + printf 'refused\t%s\tlegacy-size\n' "$p" >>"$rec" + AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); continue + fi + tmp="$R${p%/*}/.${p##*/}.migrate.$$" + rm -f -- "$tmp" 2>/dev/null || true + if ( umask 077; cat -- "$R$l" >"$tmp" ) 2>/dev/null && sync -- "$tmp" 2>/dev/null \ + && [ "$(stat -c %s -- "$tmp" 2>/dev/null)" = "$size" ] && _ab_rc_same "$R$l" "$tmp" \ + && mv -T -- "$tmp" "$R$p"; then + sync -- "$R${p%/*}" 2>/dev/null || true + read -r uid gid mode _ <<<"$(_ab_rc_stat "$R$l")" + # Pre-flag state of a copy that did not exist: devuser's, at the legacy mode. + _ab_rc_note "$p" f "${AB_RC_DEVUSER_IDS%%:*}" "${AB_RC_DEVUSER_IDS##*:}" "$mode" || AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)) + seen=copied + printf 'copied\t%s\t%s\n' "$p" "$l" >>"$rec" + AB_RC_MIGRATED=$((AB_RC_MIGRATED + 1)) + echo "[security] role-isolation: custody migrate: copied ${l} -> ${p} (verified; legacy copy left in place)" + else + rm -f -- "$tmp" 2>/dev/null || true + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: copying ${l} -> ${p} failed verification; nothing placed" >&2 + printf 'refused\t%s\tcopy-verify\n' "$p" >>"$rec" + AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); continue + fi + fi + if [ ! -e "$R$p" ] && [ ! -L "$R$p" ]; then + printf 'absent\t%s\t%s\n' "$p" "$l" >>"$rec" + continue + fi + st="$(_ab_rc_stat "$R$p")" + read -r uid gid mode links size _ <<<"$st" + local why="" + if [ -L "$R$p" ]; then why=symlink + elif [ ! -f "$R$p" ]; then why=not-regular + elif [ "${links:-0}" != 1 ]; then why=hard-linked + elif [ "${size:-0}" -eq 0 ]; then why=empty + elif [ "$size" -gt "$AB_RC_MAX_SECRET_BYTES" ]; then why=oversized + elif [ -z "$seen" ] && [ "$l" != - ] && [ -f "$R$l" ] && [ ! -L "$R$l" ] && ! _ab_rc_same "$R$l" "$R$p"; then + why=differs-from-legacy + fi + if [ -n "$why" ]; then + AB_RC_REFUSED[$p]="$why" + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: ${p} refused (${why}); not re-owned, not delivered. Inspect it, then remove it to re-migrate from ${l}" >&2 + printf 'refused\t%s\t%s\n' "$p" "$why" >>"$rec" + AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); continue + fi + _ab_rc_note "$p" f "$uid" "$gid" "$mode" || AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)) + if ! _ab_rc_set "$R$p" "${role_uid[$a]}:${role_uid[$a]}" 400; then + echo "[security] ROLE-ISOLATION-ERROR: custody migrate: cannot hand ${p} to ${a} 0400" >&2 + AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); continue + fi + if [ "$seen" != copied ]; then + if [ "$l" != - ] && [ -e "$R$l" ]; then printf 'present\t%s\t%s\n' "$p" "$l" >>"$rec" + else printf 'present\t%s\t-\n' "$p" >>"$rec"; fi + fi + done < <(_ab_rc_plan_rows "$plan") + for dir in "${!is_dir[@]}"; do + [ -n "${is_dir[$dir]}" ] || continue + _ab_rc_set "$R$dir" 0:0 "${is_dir[$dir]}" \ + || { echo "[security] ROLE-ISOLATION-ERROR: custody migrate: cannot make ${dir} root ${is_dir[$dir]}" >&2; AB_RC_MIG_FAILURES=$((AB_RC_MIG_FAILURES + 1)); } + done + unset -f _ab_rc_note + if [ "$rec" != /dev/null ]; then + chmod 0644 "$rec" 2>/dev/null || true + if [ -L "$R$record" ] || { [ -e "$R$record" ] && [ ! -f "$R$record" ]; }; then + mv -T -- "$R$record" "$R$record.squatted.$$" 2>/dev/null || true + fi + # mktemp ran as root, so the record is root's already; the rename keeps that. + mv -f -- "$rec" "$R$record" 2>/dev/null || rm -f -- "$rec" + fi + echo "[security] role-isolation: custody migrate: ${AB_RC_MIGRATED} copied, ${#AB_RC_REFUSED[@]} refused, ${AB_RC_MIG_FAILURES} problem(s); record ${record}" + return 0 +} + +# ab_custody_revert : hand every recorded registry path back to devuser (root +# if root owned it before) at its pre-flag mode. No trusted record, no change. +# Sets AB_RC_REVERTED. +ab_custody_revert() { + local plan="$1" R="${AB_RC_ROOT:-}" kind a b c modes="" p kindc ids mode n=0 + local -A want=() + AB_RC_REVERTED=0 + [ -r "$plan" ] || return 0 + while IFS=$'\t' read -r kind a b c; do + case "$kind" in + atrestdir) want[$a]=d; [ -n "$modes" ] || modes="${a}/.role-custody.modes" ;; + atrest) want[$b]=f ;; + esac + done < <(_ab_rc_plan_rows "$plan") + [ -n "$modes" ] || return 0 + if [ ! -e "$R$modes" ] && [ ! -L "$R$modes" ]; then return 0; fi + if ! _ab_rc_modes_trusted "$R$modes"; then + echo "[security] ROLE-ISOLATION-ERROR: custody revert: ${modes} is not a root-owned 0600 single-link file; nothing reverted" >&2 + return 0 + fi + while IFS=$'\t' read -r kindc p ids mode; do + case "$kindc" in d|f) ;; *) continue ;; esac + # Only registry paths, and only as the kind the plan names. + [ "${want[$p]:-}" = "$kindc" ] || continue + case "$mode" in [0-7][0-7][0-7]) ;; [0-7][0-7][0-7][0-7]) [ "$kindc" = d ] || mode="${mode#?}" ;; *) continue ;; esac + if [ ! -e "$R$p" ] || [ -L "$R$p" ]; then continue; fi + if [ "$kindc" = d ] && [ ! -d "$R$p" ]; then continue; fi + if [ "$kindc" = f ] && [ ! -f "$R$p" ]; then continue; fi + # Back to devuser; a path root owned before the flag (identities/ is made by + # root's mkdir) goes back to root. No other owner is ever restored. + [ "$ids" = 0:0 ] || ids="$AB_RC_DEVUSER_IDS" + if _ab_rc_set "$R$p" "$ids" "$mode"; then n=$((n + 1)) + else echo "[security] ROLE-ISOLATION-ERROR: custody revert: cannot hand ${p} back to devuser ${mode}" >&2; fi + done <"$R$modes" + # shellcheck disable=SC2034 # documented output (see the header above), read by callers + AB_RC_REVERTED=$n + echo "[security] role-isolation: custody revert: ${n} registry path(s) handed back to devuser" + return 0 +} diff --git a/config/nip98-proxy/proxy.mjs b/config/nip98-proxy/proxy.mjs index 9cc717aec..76474ba81 100644 --- a/config/nip98-proxy/proxy.mjs +++ b/config/nip98-proxy/proxy.mjs @@ -96,7 +96,39 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = Number.parseInt(process.env.NIP98_PROXY_PORT || '9096', 10); const BIND = process.env.NIP98_PROXY_HOST || '0.0.0.0'; const UPSTREAM_URL = process.env.AOE_UPSTREAM || 'http://127.0.0.1:9095'; -const BREAK_GLASS = process.env.NIP98_PROXY_ALLOW_BEARER || ''; +// Custody W2 (bypass 3): NIP98_PROXY_ALLOW_BEARER and NIP98_PROXY_SESSION_SECRET +// are ROLE secrets of ab-ingress (config/custody/env-classes.json). They are read +// through the shared loader (management-api/lib/role-secret.js): _FILE, +// then $AGENTBOX_SECRETS_DIR/ (W1's isolated config), and the bare +// variable only while [security].role_isolation is off. Flag off this is the +// same read as before. An unreadable file reads as unset: break-glass off, a +// per-boot session secret (fail closed, never a crash at the LAN door). +const ROLE_SECRET = (() => { + for (const c of [ + pathResolve(__dirname, '..', 'management-api', 'lib', 'role-secret.js'), + pathResolve(__dirname, '..', '..', 'management-api', 'lib', 'role-secret.js'), + '/opt/agentbox/management-api/lib/role-secret.js', + ]) { + try { return require(c); } catch { /* next */ } + } + return null; +})(); +function roleSecret(name) { + if (!ROLE_SECRET) { + if (String(process.env.AGENTBOX_ROLE_ISOLATION || '') === '1') { + console.error(`[nip98-proxy] role-secret loader unavailable: ${name} not read under role isolation`); + return ''; + } + return process.env[name] || ''; + } + try { + return ROLE_SECRET.readRoleSecret(name, { log: (l) => console.error(`[nip98-proxy] ${l}`) }); + } catch (err) { + console.error(`[nip98-proxy] ${err.message}`); + return ''; + } +} +const BREAK_GLASS = roleSecret('NIP98_PROXY_ALLOW_BEARER'); const BREAK_GLASS_PUBKEY = process.env.NIP98_PROXY_BEARER_PUBKEY || 'break-glass'; // ── ADR-2027 (closeout 2026-09-05): break-glass is BOUNDED authority ───────── @@ -209,7 +241,8 @@ const SESSION_TTL_S = Number.parseInt(process.env.NIP98_PROXY_SESSION_TTL || '43 if (!Number.isSafeInteger(SESSION_TTL_S) || SESSION_TTL_S <= 0) { throw new Error('NIP98_PROXY_SESSION_TTL must be a positive integer'); } -const SESSION_SECRET = process.env.NIP98_PROXY_SESSION_SECRET || crypto.randomBytes(32).toString('hex'); +const PINNED_SESSION_SECRET = roleSecret('NIP98_PROXY_SESSION_SECRET'); +const SESSION_SECRET = PINNED_SESSION_SECRET || crypto.randomBytes(32).toString('hex'); const SESSION_COOKIE = 'agentbox_nip07_session'; // Optional npub gate (ADR-045 D2): restrict which verified identities are @@ -1191,7 +1224,7 @@ server.listen(PORT, BIND, () => { audit: 'per-use, by fingerprint, to the proxy log (in-process counters are not a durable audit store)', } : 'disabled', - nip07Sessions: `enabled (ttl ${SESSION_TTL_S}s${process.env.NIP98_PROXY_SESSION_SECRET ? ', pinned secret' : ', per-boot secret'})`, + nip07Sessions: `enabled (ttl ${SESSION_TTL_S}s${PINNED_SESSION_SECRET ? ', pinned secret' : ', per-boot secret'})`, allowedPubkeys: ALLOWED_PUBKEYS.size || 'any-valid-signature', }); }); diff --git a/config/nostr-gateway/gateway.cjs b/config/nostr-gateway/gateway.cjs index 6821fd9e5..1b94295c9 100755 --- a/config/nostr-gateway/gateway.cjs +++ b/config/nostr-gateway/gateway.cjs @@ -78,6 +78,8 @@ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); +// Custody W2: the operator key, file-only under [security].role_isolation. +const { operatorKeyHex } = require(path.join(__dirname, '..', 'hooks', 'lib', 'operator-key.cjs')); const { execFile, execFileSync } = require('child_process'); // ── constants (mirror parity) ─────────────────────────────────────────────── @@ -181,7 +183,7 @@ function envFirst(...ks) { for (const k of ks) { const v = process.env[k]; if (v // 'agentbox-gateway-v1') if you ever want a dedicated bot contact instead. // In both modes the sole authorised commander is the operator, and replies go to // the operator (your phone). -const OP_HEX = envFirst('AGENTBOX_PRIVKEY_HEX', 'AGENTBOX_BRIDGE_SK', 'OPERATOR_NOSTR_PRIVKEY'); +const OP_HEX = operatorKeyHex({ log: log }); if (!/^[0-9a-f]{64}$/i.test(OP_HEX)) { log('no operator key (AGENTBOX_PRIVKEY_HEX) — exiting'); process.exit(0); } const rawSk = Uint8Array.from(Buffer.from(OP_HEX, 'hex')); const adminPub = tools.getPublicKey(rawSk).toLowerCase(); diff --git a/config/nostr-gateway/nostr-send.cjs b/config/nostr-gateway/nostr-send.cjs index 3b1f555db..57f460e7e 100755 --- a/config/nostr-gateway/nostr-send.cjs +++ b/config/nostr-gateway/nostr-send.cjs @@ -14,6 +14,8 @@ */ const crypto = require('crypto'); const path = require('path'); +// Custody W2: the operator key, file-only under [security].role_isolation. +const { operatorKeyHex } = require(path.join(__dirname, '..', 'hooks', 'lib', 'operator-key.cjs')); const DEFAULT_RELAY = 'wss://dreamlab-nostr-relay.solitary-paper-764d.workers.dev'; const KIND_DM_RUMOR = 14; @@ -29,7 +31,7 @@ const envFirst = (...ks) => { for (const k of ks) { const v = process.env[k]; if // Same identity selection as the gateway: default to the whitelisted operator/ // admin key so replies reach the recipient the relay accepts (and your phone reads). function loadIdentity() { - const hex = envFirst('AGENTBOX_PRIVKEY_HEX', 'AGENTBOX_BRIDGE_SK', 'OPERATOR_NOSTR_PRIVKEY'); + const hex = operatorKeyHex({ log: (m) => process.stderr.write(`[nostr-send] ${m}\n`) }); if (!/^[0-9a-f]{64}$/i.test(hex)) return null; const mode = (envFirst('AGENTBOX_GATEWAY_IDENTITY') || 'operator').toLowerCase(); try { diff --git a/config/role-accounts.json b/config/role-accounts.json new file mode 100644 index 000000000..6457bf81e --- /dev/null +++ b/config/role-accounts.json @@ -0,0 +1,160 @@ +{ + "$comment": "ADR-2122 (custody X-1 step 1, W1): the single source for per-role service accounts. flake.nix bakes /etc/passwd and /etc/group from it, and `agentbox-manifest role-accounts isolate` derives /etc/supervisord.roles.conf and the /run/secrets delivery plan from today's rendered supervisord.conf with it. Inert unless [security].role_isolation = true. A secret is EITHER `source` (a file path; the program's own environment= value for `env` wins over it, unless that value is the secret's `legacy` path, which the custody migrate step copies from once and never deletes) OR `from_env` (a PID-1 environment variable, written to the file and unset before supervisord). `env` names the variable the program reads the path from; the isolated config points it at /run/secrets//. Never put a secret VALUE here.", + "schema": "agentbox.role-accounts/1", + "uid_range": [960, 979], + "reserved_ids": { + "965": "host docker group: docker-compose.override.yml group_add. A role whose gid is 965 would sit in the Docker socket's group, and the Docker daemon is root on the host." + }, + "secrets_root": "/run/secrets", + "secret_bearing_programs": [ + "nostr-relay", + "serve-identity", + "nostr-gateway", + "nip98-proxy", + "sidestr-producer*", + "sidestr-faucet*" + ], + "roles": [ + { + "name": "ab-identity", + "uid": 960, + "purpose": "identity signer: the nostr-pod-bridge daemon (relay slot, NIP-59 unwrap). Sole holder of the sovereign/operator, JunkieJarvis and concierge keys; it runs the identity port (serve-identity, W3), which signs for every other caller through /run/secrets/ab-identity-port/identity.sock.", + "programs": ["nostr-relay", "serve-identity"], + "secrets": [ + { "file": "nostr.key", "from_env": "AGENTBOX_BRIDGE_SK", "env": "AGENTBOX_BRIDGE_SK_FILE" }, + { "file": "AGENTBOX_PRIVKEY_HEX", "from_env": "AGENTBOX_PRIVKEY_HEX" }, + { "file": "AGENTBOX_NSEC", "from_env": "AGENTBOX_NSEC" }, + { "file": "JUNKIEJARVIS_PRIVKEY_HEX", "from_env": "JUNKIEJARVIS_PRIVKEY_HEX" }, + { "file": "CONCIERGE_PRIVKEY_HEX", "from_env": "CONCIERGE_PRIVKEY_HEX" } + ], + "at_rest": [ + { "path": "/var/lib/agentbox/identities/agentbox-core.json", "purpose": "The sovereign identity file nostr-pod-bridge bootstrap writes (root reads it at boot). Flag off: devuser 0600 (management-api's pods signer). Flag on: ab-identity 0400; the pods signer goes through the identity port." }, + { "path": "/var/lib/agentbox/secrets/zone-keys.json", "legacy": "/home/devuser/workspace/.agentbox/zone-keys.json", "purpose": "JunkieJarvis zone keys. Copied once from the workspace bind; the workspace copy stays (Q5) and is what flag-off readers use until W3b moves the zone gate." } + ] + }, + { + "name": "ab-gateway", + "uid": 961, + "purpose": "nostr-gateway: operator DMs and relay AUTH. Holds no key; signs through the identity port (W3a), reads the AoE bearer through the aoe-share copy (design 2.6, W2).", + "programs": ["nostr-gateway"], + "secrets": [] + }, + { + "name": "ab-ingress", + "uid": 962, + "purpose": "nip98-proxy: the LAN door. Break-glass bearer and session secret.", + "programs": ["nip98-proxy"], + "secrets": [ + { "file": "NIP98_PROXY_ALLOW_BEARER", "from_env": "NIP98_PROXY_ALLOW_BEARER" }, + { "file": "NIP98_PROXY_SESSION_SECRET", "from_env": "NIP98_PROXY_SESSION_SECRET" } + ] + }, + { + "name": "ab-spend", + "uid": 963, + "purpose": "hitch host and pay402 spend keys. Account only in step 1: owner disposition Q6 defers the spend port (W5), so pay402 and Hitch stay devuser-class.", + "programs": [], + "secrets": [] + }, + { + "name": "ab-sidestr-dreamlab", + "uid": 964, + "purpose": "sidestr:dreamlab producer: block-signing key and parent RPC credential.", + "programs": ["sidestr-producer"], + "secrets": [ + { "file": "signer.key", "env": "SIDESTR_KEY", "source": "/var/lib/agentbox/secrets/sidestr-dreamlab.key" }, + { "file": "parent.credential", "env": "SIDESTR_PARENT_COOKIE", "source": "/var/lib/agentbox/secrets/sidestr-tbtc4.cookie" } + ] + }, + { + "name": "ab-faucet-dreamlab", + "uid": 966, + "purpose": "sidestr:dreamlab faucet: treasury key. 965 is skipped (reserved_ids).", + "programs": ["sidestr-faucet"], + "secrets": [ + { "file": "treasury.key", "env": "SIDESTR_FAUCET_KEY", "source": "/var/lib/agentbox/secrets/sidestr-faucet-dreamlab.key", "legacy": "/home/devuser/workspace/sidestr/agents/treasury.key" } + ] + }, + { + "name": "ab-sidestr-dreamlab-txbt4", + "uid": 967, + "purpose": "sidestr:dreamlab-txbt4 producer ([sidechain.dreamlab-txbt4], ADR-2103).", + "programs": ["sidestr-producer-dreamlab-txbt4"], + "secrets": [ + { "file": "signer.key", "env": "SIDESTR_KEY", "source": "/var/lib/agentbox/secrets/sidestr-dreamlab-txbt4.key" }, + { "file": "parent.credential", "env": "SIDESTR_PARENT_COOKIE", "source": "/var/lib/agentbox/secrets/sidestr-txbt4.rpc" } + ] + }, + { + "name": "ab-faucet-dreamlab-txbt4", + "uid": 968, + "purpose": "sidestr:dreamlab-txbt4 faucet: treasury key.", + "programs": ["sidestr-faucet-dreamlab-txbt4"], + "secrets": [ + { "file": "treasury.key", "env": "SIDESTR_FAUCET_KEY", "source": "/var/lib/agentbox/secrets/sidestr-faucet-dreamlab-txbt4.key", "legacy": "/home/devuser/workspace/sidestr/agents/treasury-dreamlab-txbt4.key" } + ] + } + ], + "groups": [ + { + "name": "ab-identity-port", + "gid": 969, + "purpose": "The identity port's socket group (ADR-2122, W3). /run/secrets/ab-identity-port/ is ab-identity:ab-identity-port 0750 inside the root-owned secrets mount, so devuser (owner of /run) cannot rename it; the socket is 0660 in this group. The group only narrows who can connect: authorisation is SO_PEERCRED against config/custody/identity-port-acl.json, whose callers are devuser (1000) and ab-gateway (961). ab-identity is a member because chgrp needs membership. 969 is the next free id (965 reserved).", + "members": ["devuser", "ab-identity", "ab-gateway"], + "owner": "ab-identity", + "env": "AGENTBOX_IDENTITY_SOCK_GID" + } + ], + "at_rest_dirs": [ + { "path": "/var/lib/agentbox/secrets", "mode": "700", "purpose": "The agentbox-secrets volume. Flag on: root:root 0700, so devuser cannot list or open any at-rest copy; root copies each role's files into /run/secrets// at boot." }, + { "path": "/var/lib/agentbox/identities", "mode": "711", "purpose": "The sovereign-identities volume. Flag on: root:root 0711, not 0700, because the pay402/Hitch spend keys (management-api/lib/sidestr-spend-key.js) stay devuser-class until the spend port (owner decision Q6) and are opened by known name. devuser cannot list it, and agentbox-core.json inside it is ab-identity 0400." } + ], + "dirs": [ + { + "path": "/var/lib/agentbox/events/sign", + "owner": "ab-identity", + "group": "devuser", + "mode": "2750", + "purpose": "Identity-port sign receipts: written by ab-identity, read by devuser (the rehearsal, operators). The parent is devuser-owned, so the port cannot create it itself." + }, + { + "path": "/var/lib/agentbox/events/sidestr", + "owner": "root", + "group": "root", + "mode": "755", + "purpose": "Custody W4: sidechain state on the agentbox-events volume. Under the flag a role program's HOME is on the /run/secrets tmpfs, so the workspace defaults would lose the chain on every boot." + }, + { + "path": "/var/lib/agentbox/events/sidestr/dreamlab", + "owner": "ab-sidestr-dreamlab", + "group": "devuser", + "mode": "2750", + "seed": [{ "from": "/home/devuser/workspace/sidestr/dreamlab" }], + "purpose": "sidestr:dreamlab block files (run-producer.sh SIDESTR_STATE under the flag). The producer writes (umask 027); the devuser mirror reads through the group. Seeded once from the workspace state, which stays." + }, + { + "path": "/var/lib/agentbox/events/sidestr/dreamlab-txbt4", + "owner": "ab-sidestr-dreamlab-txbt4", + "group": "devuser", + "mode": "2750", + "seed": [{ "from": "/home/devuser/workspace/sidestr/dreamlab-txbt4" }], + "purpose": "sidestr:dreamlab-txbt4 block files, as for dreamlab." + }, + { + "path": "/var/lib/agentbox/events/sidestr/faucet-dreamlab", + "owner": "ab-faucet-dreamlab", + "group": "devuser", + "mode": "2750", + "seed": [{ "from": "/home/devuser/workspace/sidestr/agents/faucet.json", "to": "faucet.json" }], + "purpose": "sidestr:dreamlab faucet grant ledger (run-faucet.sh SIDESTR_FAUCET_STATE under the flag). Losing it would re-grant every claimant. Seeded once from the workspace ledger, which stays. Group devuser reads it (grant records, no key) so a flag-off run-faucet.sh can refuse a stale workspace ledger." + }, + { + "path": "/var/lib/agentbox/events/sidestr/faucet-dreamlab-txbt4", + "owner": "ab-faucet-dreamlab-txbt4", + "group": "devuser", + "mode": "2750", + "seed": [{ "from": "/home/devuser/workspace/sidestr/agents/faucet-dreamlab-txbt4.json", "to": "faucet.json" }], + "purpose": "sidestr:dreamlab-txbt4 faucet grant ledger, as for dreamlab." + } + ] +} diff --git a/config/sidechain/README.md b/config/sidechain/README.md index a12a4aa3b..5a805c63d 100644 --- a/config/sidechain/README.md +++ b/config/sidechain/README.md @@ -114,7 +114,7 @@ for four days with nothing to bring it back. | program | gate | runs | does | |---|---|---|---| -| `sidestr-producer` | `enabled` | `run-producer.sh --announce-mirror ` | the upstream JS engine from the durable checkouts under `$WORKSPACE/sidestr/upstream`: port `:3450` on loopback, a block every 600 s (10 s with transactions), the five default public relays, peg-ins scanned on the estate's testnet4 node from the funding height and paid from wallet `sidestr-peg` | +| `sidestr-producer` | `enabled` | `run-producer.sh --announce-mirror ` | the upstream JS engine baked at the pins (`lib/sidestr-upstream.nix`, `/opt/agentbox/sidestr/upstream`): port `:3450` on loopback, a block every 600 s (10 s with transactions), the five default public relays, peg-ins scanned on the estate's testnet4 node from the funding height and paid from wallet `sidestr-peg` | | `sidestr-mirror` | `mirror` | `mirror-sync.sh 120` | copies `chain.json`, `blocks.dat` and `blocks.json` into a GitHub Pages checkout and pushes on change | | `sidestr-faucet` | `faucet` | `run-faucet.sh` | `sidestr-agent faucet` (baked, `lib/sidestr-agent.nix`): 100 DREAM and 1,000 sats per script per 24 h, 20 grants an hour, paid from `faucet_key_file` | @@ -126,10 +126,23 @@ that has announced, and `play-grounds.github.io/sidestr` is one such client. Wit producer makes blocks that no wallet can find. GitHub Pages serves the mirror with open CORS and Range requests, which is all a mirror is. -The producer refuses to start unless each checkout is at the commit recorded in -`upstream-pins`, so the upstream code the chain runs is a fact of this repository, not of -the host; supervisord gives up after five attempts (FATAL), because the fix is a pin, not -a retry. `SIDESTR_ALLOW_UNPINNED=1` overrides the check for an upgrade test. +The producer runs the image's bake of the commits in `upstream-pins` (`lib/sidestr-upstream.nix`): +sidestr/spec, bitcoin-desktop/schema and bitcoin-blake/blaketestnode in one read-only +`/nix/store` tree linked at `/opt/agentbox/sidestr/upstream`. The producer holds the chain's +signing key, so it runs only code its own user cannot change; a workspace checkout could be +edited without a commit, and the old `HEAD` comparison let that pass. Each baked directory +records its commit in `.pin-commit`. The runner refuses to start when one differs from +`upstream-pins` (a stale bake), when any file in the bake is writable, or when the image ships +no bake. Nix evaluation already fails when `lib/sidestr-upstream.nix` and `upstream-pins` +disagree, so a pin moves together with its hash and the next rebuild picks it up. +supervisord gives up after five attempts (FATAL), because the fix is a rebuild, not a retry. + +`SIDESTR_ALLOW_UNPINNED=1` runs the workspace checkout `SIDESTR_UPSTREAM` (default +`$WORKSPACE/sidestr/upstream`) instead, at any commit, for an upgrade test. Every start then +logs `SIDESTR-UNPINNED`, naming each directory that is off its pin or has uncommitted edits. +`SIDESTR_UPSTREAM` without the override is refused. The bake carries no `node_modules`: siding +loads `@ethereumjs/*` only for a chain whose document names the `evm` rule, and neither estate +chain does; the runner refuses such a chain on this bake. ### Further chains: `[sidechain.]` diff --git a/config/sidechain/mirror-sync.sh b/config/sidechain/mirror-sync.sh index bc96a03f3..3f4998f02 100755 --- a/config/sidechain/mirror-sync.sh +++ b/config/sidechain/mirror-sync.sh @@ -24,7 +24,10 @@ set -euo pipefail WORKSPACE="${WORKSPACE:-$HOME/workspace}" NAME="${SIDESTR_CHAIN:-dreamlab}" -STATE="${SIDESTR_STATE:-$WORKSPACE/sidestr/$NAME}" +# Custody W4: under [security].role_isolation the producer's state is on the agentbox-events +# volume, owned by its role with group devuser (2750); this devuser loop reads it through the group. +if [ "${AGENTBOX_ROLE_ISOLATION:-0}" = 1 ]; then STATE="${SIDESTR_STATE:-${SIDESTR_CUSTODY_ROOT:-/var/lib/agentbox/events/sidestr}/$NAME}" +else STATE="${SIDESTR_STATE:-$WORKSPACE/sidestr/$NAME}"; fi PRODUCER="${SIDESTR_PRODUCER_URL:-http://127.0.0.1:${SIDESTR_PORT:-3450}}" DOC="${SIDESTR_DOC:-$(cd "$(dirname "$0")" && pwd)/$NAME/chain.json}" # Beside the document first; the image's document dir is read-only, so the event normally sits in diff --git a/config/sidechain/run-faucet.sh b/config/sidechain/run-faucet.sh index b1ebf5398..59cb577f3 100755 --- a/config/sidechain/run-faucet.sh +++ b/config/sidechain/run-faucet.sh @@ -27,6 +27,20 @@ else KEY="${SIDESTR_FAUCET_KEY:-$WORKSPACE/sidestr/agents/treasury-$NAME.key}" STATE="${SIDESTR_FAUCET_STATE:-$WORKSPACE/sidestr/agents/faucet-$NAME.json}" fi +# Custody W4: under [security].role_isolation the grant ledger is on the agentbox-events volume, +# owned by this faucet's role (its HOME is on the /run/secrets tmpfs: a workspace-default ledger +# would be empty every boot and re-grant every claimant). Seeded once from the workspace ledger, +# which stays. Flag off again with a custody ledger longer than the workspace one: refuse, since +# the workspace ledger has forgotten grants made under the flag. +CUSTODY_LEDGER="${SIDESTR_CUSTODY_ROOT:-/var/lib/agentbox/events/sidestr}/faucet-$NAME/faucet.json" +if [ "${AGENTBOX_ROLE_ISOLATION:-0}" = 1 ]; then + [ -n "${SIDESTR_FAUCET_STATE:-}" ] || STATE="$CUSTODY_LEDGER" + umask 027 +elif [ -z "${SIDESTR_FAUCET_STATE:-}" ] && [ -r "$CUSTODY_LEDGER" ] \ + && [ "$(stat -c %s "$CUSTODY_LEDGER")" -gt "$(stat -c %s "$STATE" 2>/dev/null || echo 0)" ]; then + echo "run-faucet[$NAME]: CUSTODY-STATE-AHEAD: $CUSTODY_LEDGER is longer than $STATE (grants made under role_isolation). Refusing to re-grant: copy it over $STATE, or set SIDESTR_FAUCET_STATE." >&2 + exit 1 +fi PRODUCER="http://127.0.0.1:${SIDESTR_PORT:-3450}" ASSET="${SIDESTR_FAUCET_ASSET-DREAM}" # unset: DREAM; set empty: sats only diff --git a/config/sidechain/run-producer.sh b/config/sidechain/run-producer.sh index 6c1fb5682..57391b3ad 100755 --- a/config/sidechain/run-producer.sh +++ b/config/sidechain/run-producer.sh @@ -3,17 +3,29 @@ # sidestr:dreamlab (gate [sidechain].enabled) and [program:sidestr-producer-] for each # [sidechain.] table (gate [sidechain.].enabled, dominated by [sidechain].enabled). # flake.nix passes --announce-mirror from the table's announce_mirror and the chain's settings as -# the environment below. Runs the upstream JS reference engine from durable checkouts under -# $WORKSPACE/sidestr/upstream against the chain's parent node. Keys and RPC credentials are files -# under /var/lib/agentbox/secrets, never arguments. +# the environment below. Runs the upstream JS reference engine against the chain's parent node. +# Keys and RPC credentials are files under /var/lib/agentbox/secrets, never arguments. +# +# Which upstream runs (custody W5, design §2.7, owner Q7). The upstream code is consensus for the +# chain and the producer holds the chain's signing key, so by default it runs only code its own user +# cannot modify: the image's bake of the commits in upstream-pins (lib/sidestr-upstream.nix, linked +# at /opt/agentbox/sidestr/upstream, a read-only /nix/store tree). Each baked directory carries +# .pin-commit, written at build time; one that differs from upstream-pins is a stale bake and the +# runner refuses, as it does when any file in the bake is writable. A workspace checkout runs only +# under SIDESTR_ALLOW_UNPINNED=1, and every start then logs SIDESTR-UNPINNED. This default holds +# whether or not [security].role_isolation is on. # # run-producer.sh [--announce-mirror https://host/path] # # Environment (all optional; the defaults are sidestr:dreamlab's, unchanged since 2026-09-22): # SIDESTR_CHAIN the chain's name: config/sidechain//chain.json, the signer key # /var/lib/agentbox/secrets/sidestr-.key, state $WORKSPACE/sidestr/ -# SIDESTR_UPSTREAM checkouts of sidestr/spec, bitcoin-desktop/schema, bitcoin-blake/blaketestnode -# SIDESTR_STATE the chain's block file directory +# SIDESTR_UPSTREAM_BAKED the baked sidestr/spec, bitcoin-desktop/schema, bitcoin-blake/blaketestnode +# (default /opt/agentbox/sidestr/upstream) +# SIDESTR_UPSTREAM checkouts of the same three; read only under SIDESTR_ALLOW_UNPINNED=1 +# (default $WORKSPACE/sidestr/upstream) +# SIDESTR_STATE the chain's block file directory (default: the workspace path above; +# /var/lib/agentbox/events/sidestr/ under [security].role_isolation) # SIDESTR_DOC the sealed chain document # SIDESTR_KEY the signer key file # SIDESTR_EXPECT_PARENT the parent the manifest declares; a document naming another is a boot @@ -28,14 +40,31 @@ # 0 (the default): no checkpoint, so nothing anchors the chain. N > 0 needs # a wallet: the engine would otherwise skip every checkpoint without a word. # SIDESTR_PORT / SIDESTR_INTERVAL / SIDESTR_RELAYS -# SIDESTR_ALLOW_UNPINNED=1 run an upstream checkout other than upstream-pins (upgrade tests only) +# SIDESTR_ALLOW_UNPINNED=1 run the SIDESTR_UPSTREAM checkout instead of the bake, at any commit +# (upgrade tests only; logs SIDESTR-UNPINNED) set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" WORKSPACE="${WORKSPACE:-$HOME/workspace}" NAME="${SIDESTR_CHAIN:-dreamlab}" -UP="${SIDESTR_UPSTREAM:-$WORKSPACE/sidestr/upstream}" -STATE="${SIDESTR_STATE:-$WORKSPACE/sidestr/$NAME}" +# Custody W4 (ADR-2122): under [security].role_isolation the state lives on the agentbox-events +# volume, owned by this chain's role (group devuser, so the mirror reads it); the role's HOME is on +# the /run/secrets tmpfs, so the workspace default would restart the chain every boot. The entrypoint +# seeds it once from the workspace copy, which stays. With the flag off again, a custody copy that +# has grown past the workspace copy means blocks were made under the flag: starting from the +# workspace would fork the chain, so refuse and say how to recover. +CUSTODY_STATE="${SIDESTR_CUSTODY_ROOT:-/var/lib/agentbox/events/sidestr}/$NAME" +if [ "${AGENTBOX_ROLE_ISOLATION:-0}" = 1 ]; then + STATE="${SIDESTR_STATE:-$CUSTODY_STATE}" + umask 027 +else + STATE="${SIDESTR_STATE:-$WORKSPACE/sidestr/$NAME}" + if [ -z "${SIDESTR_STATE:-}" ] && [ -r "$CUSTODY_STATE/blocks.dat" ] \ + && [ "$(stat -c %s "$CUSTODY_STATE/blocks.dat")" -gt "$(stat -c %s "$STATE/blocks.dat" 2>/dev/null || echo 0)" ]; then + echo "run-producer[$NAME]: CUSTODY-STATE-AHEAD: $CUSTODY_STATE/blocks.dat is longer than $STATE/blocks.dat (blocks made under role_isolation). Refusing to fork: copy the custody state into $STATE, or set SIDESTR_STATE=$CUSTODY_STATE." >&2 + exit 1 + fi +fi DOC="${SIDESTR_DOC:-$HERE/$NAME/chain.json}" KEY="${SIDESTR_KEY:-/var/lib/agentbox/secrets/sidestr-$NAME.key}" COOKIE="${SIDESTR_PARENT_COOKIE:-/var/lib/agentbox/secrets/sidestr-tbtc4.cookie}" @@ -50,22 +79,41 @@ RELAYS="${SIDESTR_RELAYS:-wss://nos.lol,wss://relay.damus.io,wss://relay.primal. die() { echo "run-producer[$NAME]: $*" >&2; exit 1; } +PINS="$HERE/upstream-pins" +if [ "${SIDESTR_ALLOW_UNPINNED:-0}" = 1 ]; then + UP="${SIDESTR_UPSTREAM:-$WORKSPACE/sidestr/upstream}" + echo "run-producer[$NAME]: SIDESTR-UNPINNED: running consensus code from the writable checkout $UP, not the baked pins (SIDESTR_ALLOW_UNPINNED=1; upgrade tests only)" >&2 + while read -r dir want _; do + case "$dir" in ''|'#'*) continue ;; esac + have=$(git -C "$UP/$dir" rev-parse HEAD 2>/dev/null || echo none) + dirty="" + [ -n "$(git -C "$UP/$dir" status --porcelain --untracked-files=no 2>/dev/null)" ] && dirty=" with uncommitted edits" + if [ "$have" != "$want" ] || [ -n "$dirty" ]; then + echo "run-producer[$NAME]: SIDESTR-UNPINNED: $dir is at $have$dirty, pinned $want" >&2 + fi + done < "$PINS" +else + [ -z "${SIDESTR_UPSTREAM:-}" ] || die "SIDESTR_UPSTREAM=$SIDESTR_UPSTREAM names a checkout; a checkout runs only under SIDESTR_ALLOW_UNPINNED=1" + UP="${SIDESTR_UPSTREAM_BAKED:-/opt/agentbox/sidestr/upstream}" + [ -d "$UP/" ] || die "no baked upstream at $UP (this image does not ship one); rebuild, or run a checkout with SIDESTR_ALLOW_UNPINNED=1" + while read -r dir want _; do + case "$dir" in ''|'#'*) continue ;; esac + have=$(cat "$UP/$dir/.pin-commit" 2>/dev/null || echo none) + [ "$have" = "$want" ] || die "stale bake: $UP/$dir records $have but upstream-pins says $want; rebuild the image" + done < "$PINS" + # The bake is the producer's guarantee only while its user cannot change it. + w="$(find "$UP/" -writable -print -quit 2>/dev/null)" + [ -z "$w" ] || die "$w is writable by $(id -un); the baked upstream must be immutable to the producer" +fi for f in "$KEY" "$COOKIE" "$DOC" "$UP/spec/siding/bin/siding.mjs" "$UP/schema/codec/kernel.js" "$UP/blaketestnode/lib/node.mjs"; do [ -r "$f" ] || die "missing $f" done -# The upstream code is consensus for this chain: run only the commits recorded in upstream-pins. -PINS="$HERE/upstream-pins" -while read -r dir want _; do - case "$dir" in ''|'#'*) continue ;; esac - have=$(git -C "$UP/$dir" rev-parse HEAD 2>/dev/null || echo none) - if [ "$have" != "$want" ]; then - if [ "${SIDESTR_ALLOW_UNPINNED:-0}" = 1 ]; then - echo "run-producer[$NAME]: WARNING $dir is at $have, pinned $want (SIDESTR_ALLOW_UNPINNED=1)" >&2 - else - die "$UP/$dir is at $have but upstream-pins says $want; check it out or bump the pin" - fi - fi -done < "$PINS" +# siding loads ethereumjs lazily, only for a chain whose document names the evm rule, so a missing +# package would surface blocks later. The bake carries no node_modules (no estate chain names the +# rule; lib/sidestr-upstream.nix): refuse such a chain here instead. +if node -e 'const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.exit((c.rules ?? []).some((r) => (typeof r === "string" ? r : r.name) === "evm") ? 0 : 1)' "$DOC"; then + [ -d "$UP/spec/siding/node_modules/@ethereumjs/vm" ] || die "$DOC names the evm rule but $UP/spec/siding has no @ethereumjs packages" +fi # ADR-2103 D3: the sealed document says which parent this chain sits beside; the manifest only # repeats it. A disagreement is a boot failure. diff --git a/config/sidechain/upstream-pins b/config/sidechain/upstream-pins index 1583445ab..f8cf7bcd5 100644 --- a/config/sidechain/upstream-pins +++ b/config/sidechain/upstream-pins @@ -1,8 +1,10 @@ # The upstream commits the interim producer (run-producer.sh) is proven against. -# One line per checkout under $SIDESTR_UPSTREAM: . -# Bump a line only after the chain has been restarted and a block produced on that commit; -# the runner refuses any other checkout (override for a deliberate upgrade test: -# SIDESTR_ALLOW_UNPINNED=1). sidestr-rs's CI oracle job pins its own spec commit (e8deb63, SPEC 0.0.5, SIDESTR_SPEC_REF), not this one. +# One line per upstream directory: . +# lib/sidestr-upstream.nix bakes these commits into the image (/opt/agentbox/sidestr/upstream) +# and evaluation fails when its rev and hash do not match the line here; the runner refuses a +# bake whose recorded .pin-commit differs. Bump a line only after the chain has been restarted +# and a block produced on that commit, which means a run from a checkout under +# SIDESTR_ALLOW_UNPINNED=1 first, then this line, the rev and hash, and a rebuild. sidestr-rs's CI oracle job pins its own spec commit (e8deb63, SPEC 0.0.5, SIDESTR_SPEC_REF), not this one. spec e8deb63161c7459ed39c01d2ca9fda3d860b65b6 # sidestr/spec: SPEC 0.0.5 / package 0.0.7 (halt fix c3b9e7a, peg-in scan policy 76c3119); first block on it at 951 749f57af (2026-10-02) schema b8cbf6337c7450fe14ddc5bce00c7280059aab5d # bitcoin-desktop/schema v0.0.27 blaketestnode de33b347ccde458f5b82c33b3bab16abd328a7ec # bitcoin-blake/blaketestnode; operator-accepted active development head (2026-10-01) diff --git a/docker-compose.browsercontainer.yml b/docker-compose.browsercontainer.yml index 67642ad7f..25baa8a64 100644 --- a/docker-compose.browsercontainer.yml +++ b/docker-compose.browsercontainer.yml @@ -59,6 +59,9 @@ services: volumes: # Shared exchange volume — mermaid renders here for agentbox/gui-tools pickup. - gui-tools-exchange:/home/devuser/exchange + # Chrome profile (W9): Podkey's chrome.storage.local (encrypted key vault, + # public key, consent grants) must survive container recreation. + - browsercontainer-profile:/home/devuser/chrome-profile networks: visionclaw: @@ -67,3 +70,7 @@ networks: volumes: gui-tools-exchange: + # Stable name, independent of the compose project. Holds the sidecar's identity + # (K_browser, sealed in Podkey's vault): never `down -v` this file. + browsercontainer-profile: + name: browsercontainer-profile diff --git a/docker-compose.hp.yml b/docker-compose.hp.yml index 1684f6c01..824d79a80 100644 --- a/docker-compose.hp.yml +++ b/docker-compose.hp.yml @@ -17,8 +17,8 @@ services: - DISPLAY=:1 - WLR_BACKENDS=headless - AGENTBOX_NODE_LABEL=hp-desktop - - AGENTBOX_NSEC=${AGENTBOX_NSEC:-} - - AGENTBOX_PRIVKEY_HEX=${AGENTBOX_PRIVKEY_HEX:-} + # AGENTBOX_NSEC / AGENTBOX_PRIVKEY_HEX arrive by env_file (.env), never + # here: ROLE-class (config/custody/env-classes.json; custody W10 2026-10-03). - COMFYUI_OUTPUTS=/run/comfyui/output volumes: # Legacy whole-directory bind: this node has not yet run diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 28e56d7bd..d7e59dac7 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -50,15 +50,14 @@ services: # "drm": GPU-backed compositor (composited Wayland rendering on NVIDIA). # Requires nvidia-drm with modeset=1 on the host and /dev/dri/card0 mapped. - WLR_BACKENDS=${WLR_BACKENDS:-headless} - # Tailscale — set authkey for headless join, or omit for interactive login - - TAILSCALE_AUTHKEY=${TAILSCALE_AUTHKEY:-} - # Agent signing identity — set one of these in .env to use a stable - # Nostr keypair instead of auto-generating on first boot. - # AGENTBOX_NSEC accepts bech32 nsec1… (preferred — copy from any Nostr client). - # AGENTBOX_PRIVKEY_HEX accepts the raw 64-char hex private key. - # Both are untracked (.env is gitignored). Leave blank to auto-generate. - - AGENTBOX_NSEC=${AGENTBOX_NSEC:-} - - AGENTBOX_PRIVKEY_HEX=${AGENTBOX_PRIVKEY_HEX:-} + # ROLE-class secrets are NOT listed here (custody W10, 2026-10-03): + # TAILSCALE_AUTHKEY (headless tailnet join), AGENTBOX_NSEC (bech32 nsec1…) + # and AGENTBOX_PRIVKEY_HEX (64-hex) — set them in .env and env_file above + # carries them. Under [security].role_isolation the entrypoint moves each + # into /run/secrets// before PID 1 execs; an environment: entry would + # be a second route into PID 1. Gate: tests/security/compose-role-env.test.mjs + # (names from config/custody/env-classes.json). Leave the nsec blank to + # auto-generate the identity on first boot. # Additional volumes (Q16 — override surface). # Base owns: agentbox.toml ro, baseline named volumes (ruvector-data, @@ -108,9 +107,13 @@ services: # Q21: Docker socket mount (accepted security trade-off — local dev host). # Allows agentbox to orchestrate sibling containers on visionclaw_network. # See ADR (Q21) for the documented escalation: host-escape risk in - # combination with SYS_ADMIN. On multi-tenant or hardened deployments, - # replace with a tecnativa/docker-socket-proxy sidecar restricted to - # specific GET endpoints. + # combination with SYS_ADMIN. The Docker daemon is root on the host. + # Custody X-1 step 1 (W0): with [security].role_isolation = true the + # entrypoint no longer widens this socket for devuser; devuser gets the + # in-container GET-only proxy /run/docker-ro.sock ([program:docker-read-proxy], + # config/docker-read-proxy.cjs) and docker exec/run move to the host shell. + # This is the HOST inode: earlier boots left it o+rw, and only a host-side + # `chmod 0660 /var/run/docker.sock` narrows it (risk R2, owner question Q2). - /var/run/docker.sock:/var/run/docker.sock:rw # X11 socket is provided by the desktop security exception's tmpfs # in the base compose (in-container VNC at :5902). Host X11 @@ -151,9 +154,11 @@ services: # security_opt (no-new-privileges + seccomp), cap_add and shm_size are set # in the generated base docker-compose.yml ([resources].shm). - # Docker socket access: host docker group is gid 965. Without this, - # devuser (uid 1000) can't talk to /var/run/docker.sock and every - # docker/compose command requires sudo (blocked by no-new-privileges). + # Docker socket group: the host docker group is gid 965. group_add reaches + # PID 1 (root) and processes Docker starts directly; supervisord programs that + # drop to devuser (user=devuser) do NOT get it, so devuser's access came from + # the entrypoint's o+rw on the socket, which role_isolation turns off (W0). + # The read proxy joins the socket's group itself before dropping to 65534. group_add: - "965" diff --git a/docker-compose.yml b/docker-compose.yml index 529112aa0..a8d93e77f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,6 +107,7 @@ services: tmpfs: - /tmp:mode=1777,size=8G - /run:mode=755,size=256M,uid=1000,gid=1000 + - /run/secrets:mode=711,size=8M,uid=0,gid=0,noexec,nosuid,nodev - /var/run:mode=755,size=16M,uid=1000,gid=1000 - /var/log:mode=755,size=128M,uid=1000,gid=1000 - /var/log/supervisor:mode=755,size=64M,uid=1000,gid=1000 diff --git a/docs/BASELINE-container.md b/docs/BASELINE-container.md index 141c32303..3d4cbe353 100644 --- a/docs/BASELINE-container.md +++ b/docs/BASELINE-container.md @@ -1,10 +1,11 @@ --- title: Agentbox Container Baseline doc_id: AB-BASELINE -version: 0.5.2 +version: 0.5.3 status: draft-for-ratification verified_commit: changelog: + - "0.5.3 (2026-10-03): custody X-1 step 1 (ADR-2122, staged). Under [security].role_isolation (boot-class, default off), role programs run under ab-* uids from /etc/supervisord.roles.conf, secrets sit in the root-owned /run/secrets//, and devuser's Docker CLI is GET-only. New programs serve-identity and docker-read-proxy (EXITED while off). The sidecar gains podkey-loader. The sidestr producer runs the Nix bake of upstream-pins in both modes." - "0.5.2 (2026-10-01): CLI, JavaScript and Rust dependency refresh. Fastify 5 compatibility preserves NIP-98 host ports, redirects, WebSocket callbacks, CORS methods and metrics. Stale cargo binaries are quarantined with numbered recovery copies. No data-volume or memory-geometry migration." - "0.5.1 (2026-09-30): Supervised interim testnet producer, Pages mirror and standalone sidestr-agent faucet under rebuild-class sidechain gates; catalogue now 79 entries. Native node, bridge and proposed settlement invariants remain deferred." - "0.5.0 (2026-09-29): ADR-2118. Two new invariants: the global and workspace instruction tiers are generated every boot from config/instructions/ (tracked public layer + gitignored local/ estate layer, mounted read-only, never baked), and ~/.claude is a container-owned volume sharing only .credentials.json with the host via [program:claude-cred-sync]. New open item for the partial rollout (connected node, ~/.config/claude, Q43, profiles). CATALOGUE count corrected to 78 (was stale at 60)." @@ -53,7 +54,7 @@ The image is composed entirely by `flake.nix` (3,297 lines). `agentbox.toml` is ### Supervised services (real `[program:*]` blocks in flake.nix) -Supervisord runs as PID 1 root; every long-running program drops to `user=devuser`. Enumerated from the generated supervisor text: +Supervisord runs as PID 1 root; every long-running program drops to `user=devuser`, with one exception. Under `[security].role_isolation = true` (ADR-2122; boot-class, default off, staged until its rehearsal passes), the entrypoint execs `/etc/supervisord.roles.conf`. That config is derived from this one and differs only in `user=` and `environment=`. In it `nostr-relay` and `serve-identity` run as `ab-identity`, `nostr-gateway` as `ab-gateway`, `nip98-proxy` as `ab-ingress`, and each sidestr producer and faucet as its chain's role (`config/role-accounts.json`). Enumerated from the generated supervisor text: | Program | Role | Port (bind) | |---|---|---| @@ -68,6 +69,8 @@ Supervisord runs as PID 1 root; every long-running program drops to `user=devuse | `https-bridge` | pod HTTPS bridge | — | | `nostr-relay` | sovereign relay | `7777` (gated expose) | | `nostr-gateway` | nostr gateway | — | +| `serve-identity` | identity port (ADR-2122, W3): named signing operations on `/run/secrets/ab-identity-port/identity.sock`, authorised by `SO_PEERCRED` uid; exits 0 (`EXITED`) while `role_isolation` is off | unix socket | +| `docker-read-proxy` | GET-only Docker proxy for devuser under `role_isolation` (W0); starts as root, drops to 65534; exits 0 while off | `/run/docker-ro.sock` | | `opf-router` | privacy-filter redaction sidecar (legacy ADR-008), gate `[privacy_filter]` | `127.0.0.1:9092` | | `ruvector-aggregate-sweep` / `ruvector-pattern-distill` | memory sweep + distil loops | — | | `ontology-condense-scheduler` | ontology condensation | — | @@ -79,7 +82,7 @@ Supervisord runs as PID 1 root; every long-running program drops to `user=devuse | `xvnc` / `x11vnc` / `wayvnc` / `xorg-nvidia` / `hyprland` / `i3wm` / `xwayland-session` | desktop stack (gated `desktop.enabled`) | `127.0.0.1:5901` | | `tailscaled` / `tailscale-up` | mesh networking (gated) | — | | `podcast-cron` / `forum-backup-cron` | scheduled jobs | — | -| `sidestr-producer` / `sidestr-mirror` / `sidestr-faucet` | sidestr:dreamlab chain, Pages mirror, DREAM faucet (gated `[sidechain]`, rebuild-class) | `127.0.0.1:3450` | +| `sidestr-producer` / `sidestr-mirror` / `sidestr-faucet` | sidestr:dreamlab chain, Pages mirror, DREAM faucet (gated `[sidechain]`, rebuild-class). The producer runs the read-only Nix bake of `config/sidechain/upstream-pins` (`/opt/agentbox/sidestr/upstream`), never a workspace checkout unless `SIDESTR_ALLOW_UNPINNED=1` | `127.0.0.1:3450` | Readiness (`server.js:508`) requires `bootstrap.done`, `adapters:healthy`, and `paths:accessible`; `bootstrap-seal` is a one-shot at `priority=99` — if it times out `/ready` stays 503. @@ -108,7 +111,7 @@ Applied only when `gpu.backend == "local-cuda"` (`flake.nix:170`); on `backend=n Not supervised inside the box — external compose services on `visionclaw_network`, managed via `./agentbox.sh `: -- **browsercontainer** — GPU Chrome, chrome-devtools-mcp at `:8931/sse` (`system-manifest.js:154`, apply-class `live`). +- **browsercontainer** — GPU Chrome, chrome-devtools-mcp at `:8931/sse` (`system-manifest.js:154`, apply-class `live`). `[program:podkey-loader]` loads the pinned Podkey extension over CDP once per Chrome process. Its vault, holding K_browser, persists on the `browsercontainer-profile` volume; the owner mints and unlocks it (`./agentbox.sh browsercontainer podkey status|pubkey|unlock`, `browsercontainer/README.md`). - **gui-tools-service** — FHS GPU sidecar for BlenderMCP `:9876` and QGIS `:9877` under `vglrun` (`:157`). - **voice-console** — ADR-044 Caddy origin `:8444`, Unmute voice loop + AoE board; external build context (`:160`). - **ruvector-postgres** — mandatory memory store sidecar (ADR-015); compose block generated in `flake.nix:2200`, `db ruvector`, health-gated. `ruvector-mcp.cjs` fails closed, no sql.js fallback. diff --git a/docs/LAN-door-threat-model.md b/docs/LAN-door-threat-model.md index 49457b8e8..22811dbd0 100644 --- a/docs/LAN-door-threat-model.md +++ b/docs/LAN-door-threat-model.md @@ -23,3 +23,15 @@ sibling. Separate network namespaces/credentials or authenticated VNC/CDP are required before selecting a hostile-sibling profile. Existing exceptions are recorded rather than silently widened. No credentials were copied into this document and no new listener was enabled. + +## Egress doors: the prompt egress register (G-10) — 2026-10-03 + +The table above covers doors *into* the box and the two Loom doors out of it. +Every other route by which a prompt, code, a secret-adjacent string or a key +leaves (LAN, vendor and public internet) is catalogued in the +[prompt egress register](SECURITY-profiles.md#prompt-egress-register--2026-10-03), +rendered from `config/egress-policy.json` `.register` by +`scripts/ci/render-egress-register.js` and checked in CI. The Loom rows there +(`loom-door`, `loom-raw`) cite this document's lines 16 and 17. Routes with no +gate or no accepted-egress record carry a marker in the register; this document +does not restate them. diff --git a/docs/README.md b/docs/README.md index e9011d5f3..980bcb93d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -88,6 +88,7 @@ You are adding a feature, implementing an adapter, or investigating a regression | [Architecture overview](developer/architecture.md) | How it all fits together — manifest → flake → image → runtime | | [Web interface design system](developer/web-interface-design-system.md) | Tokens and components shared by the operator cockpit, console and setup wizard | | [ComfyUI API](developer/comfyui-api.md) | REST/WebSocket surface the management API exposes for an external ComfyUI backend | +| [Turning on role_isolation](developer/role-isolation-runbook.md) | Owner runbook for ADR-2122 (staged): rebuild, flag, the two-half rehearsal, receipts, rollback, the sidecar's Podkey key | | [Identity and tracing mesh](developer/identity-mesh.md) | secp256k1 identity root, 19-kind URN namespace, adapter dispatch pipeline, credential provenance, federation invariants | | [Adapter pattern](developer/adapters.md) | Five slots × three classes; how to write a new impl | | [Native pod mesh](developer/native-pod-mesh.md) | In-container git-versioned `solid-pod-rs` tier — architecture + wiring ([PRD-007](archive/prd/PRD-007-multi-tenant-federation.md) / [ADR-010](archive/adr/ADR-010-rust-solid-pod-adoption.md)) | diff --git a/docs/SECURITY-profiles.md b/docs/SECURITY-profiles.md index 9e657b6e6..bc688bfcb 100644 --- a/docs/SECURITY-profiles.md +++ b/docs/SECURITY-profiles.md @@ -76,3 +76,233 @@ delivery. Verified with network-denial fixtures that make delivery impossible. Still open: no real transcript, provider call or relay send was exercised; the relay/read policy for the signed kind-30840 digest is a separate boundary; and the policy document remains **proposed** pending maintainer adoption. + +## Prompt egress register — 2026-10-03 + + + +Source: [`config/egress-policy.json`](../config/egress-policy.json) `.register`. Citations verified at `0919dc39a` on 2026-10-03. 31 routes. + +One row per route by which a prompt, code, a secret-adjacent string or a key can leave the container. The register catalogues routes; the same file's `paths` is the redaction contract for the two routes that implement it. A route with no gate or no accepted-egress record carries a marker. The register records the gap and does not invent a gate or a record. + +Provider credentials stay devuser-class in this step (owner disposition Q8, 2026-10-03, pending owner review). Each row names the credential it uses. + +| id | Route | What leaves | Destination class | Gate | Accepted-egress record | Notes | +|---|---|---|---|---|---|---| +| `primary-model` | Claude Code and Codex sessions (the primary model routes) | prompt, code, secret-adjacent | vendor: Anthropic API; OpenAI API | none. A running session is the egress. `[toolchains].claude` (`agentbox.toml:1849`) and `codex` (`agentbox.toml:1860`) are rebuild-class bake gates, not runtime switches. | Owner disposition Q14, 2026-10-03 (custody-isolation design §11, pending owner review): primary-model egress recorded as accepted. No ADR yet. | **[marker: no gate]** Everything an agent reads can leave: prompts, file contents, tool output. Providers declared at `agentbox.toml:1165-1169` (Anthropic, oauth) and `agentbox.toml:1171-1173` (OpenAI). Credentials `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` are projected into the container environment (`flake.nix:3432`, `flake.nix:3434`): devuser-class this step (Q8). | +| `skill-router` | Live skill router, `UserPromptSubmit` hook (ADR-2091) | prompt | vendor: TypeSafe System One, `api.typesafe.ai` (`config/hooks/lib/skill-route.cjs:33`) | `[skills.routing].router = "table"` (`agentbox.toml:962`) de-registers the hook; `hook` (`agentbox.toml:963`). An absent `TYPESAFE_API_KEY` (`config/hooks/lib/skill-route.cjs:398`) fails open without sending. | ADR-2090 (accepted, `5e213c3ec`): routing prompt may leave, skill routing only (`docs/adr/ADR-2090-skill-routing-prompt-egress.md:31-40`); per-project gates deferred, not waived (`docs/adr/ADR-2090-skill-routing-prompt-egress.md:42-45`). | The user's turn text, clamped head+tail (`config/hooks/lib/skill-route.cjs:408-414`), plus every routable skill description. No per-project bypass exists at HEAD; that is the debt ADR-2090 records. Credential `TYPESAFE_API_KEY`: devuser-class (Q8). | +| `jev-compaction` | Jev compaction via factrail (ADR-2093 policy, ADR-2121 implementation) | prompt, code | vendor: TypeSafe System One (cloud) unless repointed at the local façade | `[features.jev_compaction].enabled` (`agentbox.toml:680`); `egress = "metadata"` (`agentbox.toml:701`) narrows the payload to kinds, key names, counts and lengths. | ADR-2093 (accepted): operator decision to send transcript windows beyond ADR-2090's routing-only scope, with email fenced out (`docs/adr/ADR-2093-jev-verbatim-compaction.md:53-54`). | Tool-call arguments and results from the session transcript, capped per fitting stage. Email-tainted sessions never reach a cloud judge. Credential `TYPESAFE_API_KEY`: devuser-class (Q8). | +| `system-one-local` | Sovereign System One façade (ADR-2094) | prompt, code | LAN: `system-one-facade` on `visionclaw_network`; option embeddings to `192.168.2.132:9997` (`agentbox.toml:737`) | `[features.sovereign_system_one].enabled` (`agentbox.toml:732`), currently `false` | ADR-2094 (accepted, activation inactive): a local façade in the Jev wire format (`docs/adr/ADR-2094-local-capacity-adapting-typed-decision-facade.md:35-38`). | Carries the router and compaction payloads above. When on, nothing leaves the LAN. Inactive at HEAD, so the two vendor rows above are the live routes. | +| `loom-door` | Ontology Loom façade: ontology condense; dream nights when `llm_provider = "loom"` | prompt | LAN: `${LOOM_BASE_URL}`: the estate façade `:8084` on the HP host. Compose default is `http://loom:8080/v1` (`flake.nix:3438`); the LAN address is deployment configuration. | `[skills.ontology.condense].enabled` (`agentbox.toml:860`); `[dream_machine].llm_provider` (`agentbox.toml:2179`) or `DREAM_LLM_PROVIDER` | ADR-2070 (accepted, live): the Loom endpoints are named egress doors and knowledge work holds `:8084` (`docs/adr/ADR-2070-one-front-door-is-ingress-the-loom-raw-port-is-a-named-egress-door.md:39-47`); `docs/LAN-door-threat-model.md:16`. | Knowledge-graph class text and dream prompts. The trust is service identity, not remote attestation (`docs/LAN-door-threat-model.md:16`). | +| `loom-raw` | Loom raw model door, `loom-raw` interaction-plane session | prompt, code | LAN: Loom raw model `:8085` on the HP host | none at runtime. The session seed (`agentbox.toml:1810`) is chosen per session by the operator or agent. | ADR-2070 (accepted): the raw door is agent-choice and benchmark-only (`docs/adr/ADR-2070-one-front-door-is-ingress-the-loom-raw-port-is-a-named-egress-door.md:48-52`); `docs/LAN-door-threat-model.md:17`. | **[marker: no gate]** Raw coding sessions send their whole working context to the LAN model. The purpose restriction is policy, not runtime isolation (`docs/LAN-door-threat-model.md:17`). | +| `routing-label-embeddings` | Routing teacher label log, EXP-B8 (ADR-2110) | prompt | LAN: bge-small on Xinference, `192.168.2.132:9997` (`agentbox.toml:993`) | `[skills.routing].label_log` (`agentbox.toml:992`) | Owner decision 2026-10-02 R5b: label log on as a bounded experiment (`agentbox.toml:986-987`; `scripts/experiments/exp-b8-label-log.cjs:7-8`). | Turn text is embedded for the shadow BM25 comparison. LAN only. The experiment switches itself off at its stopping rule. | +| `consultant-codex` | Consultant MCP `consultant-codex` (`/consult codex`, auto-consultant) | prompt, code | vendor: OpenAI, via the Codex CLI (`mcp/consultants/codex/server.js:39`) | `[consultants].enabled` (`agentbox.toml:1302`); `[consultants.codex].enabled` (`agentbox.toml:1307`) | none. ADR-011 (accepted, archived) designs the consultant tier; no record accepts sending repo context to the vendor. | **[marker: no accepted record]** Full consult prompts including repository context. Drift: ADR-011 (`docs/archive/adr/ADR-011-consultation-mcps.md:182`) and `mcp/consultants/README.md:102-104` claim outbound privacy-filter redaction. No redaction call exists in `mcp/consultants/shared/` at HEAD. Codex CLI credentials: devuser-class (Q8). | +| `consultant-antigravity` | Consultant MCP `consultant-antigravity` | prompt, code | vendor: Google (Gemini), via the Antigravity CLI (`mcp/consultants/antigravity/server.js:38`) | `[consultants].enabled` (`agentbox.toml:1302`); `[consultants.antigravity].enabled` (`agentbox.toml:1313`) | none. ADR-011 designs the tier only. | **[marker: no accepted record]** As consultant-codex, including the privacy-filter drift. CLI credentials: devuser-class (Q8). | +| `consultant-zai` | Consultant MCP `consultant-zai` | prompt, code | vendor: Z.AI (GLM), via the `claude-zai` CLI (`mcp/consultants/zai/server.js:41`) | `[consultants].enabled` (`agentbox.toml:1302`); `[consultants.zai].enabled` (`agentbox.toml:1319`) | none. ADR-011 designs the tier only. | **[marker: no accepted record]** As consultant-codex. Credential `ZAI_ANTHROPIC_API_KEY`: devuser-class (Q8). | +| `consultant-perplexity` | Consultant MCP `consultant-perplexity` | prompt, code | vendor: `api.perplexity.ai` (`mcp/consultants/perplexity/server.js:17`) | `[consultants].enabled` (`agentbox.toml:1302`); `[consultants.perplexity].enabled` (`agentbox.toml:1326`) | none. ADR-011 designs the tier only. | **[marker: no accepted record]** As consultant-codex. Credential `PERPLEXITY_API_KEY` (`agentbox.toml:1189-1191`): devuser-class (Q8). | +| `consultant-deepseek` | Consultant MCP `consultant-deepseek` | prompt, code | vendor: `api.deepseek.com` (`mcp/consultants/deepseek/server.js:18`) | `[consultants].enabled` (`agentbox.toml:1302`); `[consultants.deepseek].enabled` (`agentbox.toml:1331`) | none. ADR-011 designs the tier only. | **[marker: no accepted record]** As consultant-codex. DeepSeek API key: devuser-class (Q8). | +| `zai-night` | Dream engine night provider (`services/dream-engine`) | prompt, code | vendor: Z.AI, `https://api.z.ai/api/anthropic` (`services/dream-engine/src/config.rs:381`) | `[dream_machine].llm_provider = "loom"` (`agentbox.toml:2179`) or `DREAM_LLM_PROVIDER` (`services/dream-engine/src/engine.rs:1687`); `[dream_machine].enabled` (`agentbox.toml:2163`) | none. No ADR or owner decision accepts the dream corpus leaving for Z.AI. | **[marker: no accepted record]** Nightly dream prompts built from workspace and repository context. Default provider is `zai`. Credential `ZAI_ANTHROPIC_API_KEY` (`services/dream-engine/src/engine.rs:1695`): devuser-class (Q8). | +| `session-digest` | Mobile-bridge session digest (`egress-policy.json` path `session-digest`) | prompt | vendor: `ZAI_URL` (Z.AI by default) for summarisation, then the cloud relay and the Solid pod | `[sovereign_mesh.mobile_bridge].enabled` (`agentbox.toml:98`), currently `false`; `AGENTBOX_SESSION_DIGEST` / `AGENTBOX_EGRESS` (`config/hooks/lib/egress-policy.cjs:145-148`) | ADR-030 D3 (accepted, archived): the mesh's one accepted external hop (`docs/archive/adr/ADR-030-sovereign-mesh-manifest-boundary.md:65-67`). The ADR-2026 contract is still `proposed`. | Flattened transcript text, redacted before the provider hop. The signed kind-30840 is not encrypted. | +| `ontology-monitor` | Ontology monitor `SessionEnd` hook (`config/hooks/ontology-monitor.cjs`) | prompt, code | vendor: Z.AI via the `claude-zai` CLI (`config/hooks/ontology-monitor.cjs:156`, `config/hooks/ontology-monitor.cjs:177`); proposals to the forum relay as kind 31402 | `[ontology_monitor].enabled` (`agentbox.toml:116`); `mode = "dryrun"` (`agentbox.toml:117`) stops the publish | none. This contradicts ADR-030 D3's "exactly one external data hop" (`docs/archive/adr/ADR-030-sovereign-mesh-manifest-boundary.md:67`). | **[marker: no accepted record]** Changed files plus a transcript tail of up to 12,000 characters (`config/hooks/ontology-monitor.cjs:115`). Not routed through the ADR-2026 redactor or `AGENTBOX_EGRESS`. | +| `project-tracking` | Project tracking: primers, GitHub enrichment, kind-30841 publish (ADR-035) | code, public artefact | vendor: Z.AI (primers); GitHub API (enrichment); the configured relay (kind 30841) | `[project_tracking].enabled` (`agentbox.toml:1668`); `github_enrichment` (`agentbox.toml:1671`); `primer_on_scan` (`agentbox.toml:1673`); `nostr_publish` (`agentbox.toml:1674`) | ADR-035 D5 (accepted, archived): fail-open nostr publish and GitHub enrichment (`docs/archive/adr/ADR-035-project-tracking-telemetry-and-nostr-kind.md:105`). The Z.AI primer hop is not separately accepted. | Primers summarise repository content. Credential `GITHUB_TOKEN`: devuser-class (Q8). | +| `live-mirror` | Per-turn session mirror, NIP-59 (`config/hooks/nostr-live-mirror.cjs`) | prompt | public internet: One relay: `NOSTR_MIRROR_RELAY`, else the cloud default (`config/hooks/nostr-live-mirror.cjs:51`) | `AGENTBOX_LIVE_MIRROR`, `AGENTBOX_EGRESS` (`config/hooks/lib/egress-policy.cjs:145-148`); refused without a valid `AGENTBOX_MIRROR_RECIPIENTS` allowlist | ADR-029 D3 (accepted, archived): no LLM hop, cloud relay only (`docs/archive/adr/ADR-029-session-mirror-live-egress.md:67-71`). The ADR-2026 contract is still `proposed`. | Selected turn text, redacted, then gift-wrapped (kind 1059) to the operator's derived child key. | +| `nostr-gateway` | Nostr control gateway, operator command DMs (`config/nostr-gateway/gateway.cjs`) | prompt | public internet: Cloud relay (`config/nostr-gateway/gateway.cjs:84`) | `AGENTBOX_NOSTR_GATEWAY=0` (`config/nostr-gateway/gateway.cjs:166`; `flake.nix:2087`) | none. | **[marker: no accepted record]** Replies carry session output from the tmux fleet, gift-wrapped to the operator. Not routed through the ADR-2026 redactor. The operator key `AGENTBOX_PRIVKEY_HEX` is in the process environment. | +| `junkiejarvis-posts` | JunkieJarvis forum replies and clarifying DMs (`management-api/lib/junkiejarvis-agent.js`) | public artefact | public internet: The forum relay (`NOSTR_RELAYS`) | `[sovereign_mesh].junkiejarvis` (`agentbox.toml:50`), the only switch | ADR-030 (accepted, archived) places JunkieJarvis in the mesh; ADR-2088 (accepted, staged) accepts the clarifying DMs. | Kind-42 replies in forum sections and kind-1059 DMs. Encrypted to the zone key where the zone is encrypted. | +| `junkiejarvis-llm` | JunkieJarvis reply generation | prompt | vendor: Anthropic (`management-api/lib/junkiejarvis-agent.js:466`), else Z.AI or OpenAI (`management-api/lib/junkiejarvis-agent.js:496-497`) | `[sovereign_mesh].junkiejarvis` (`agentbox.toml:50`) | none. This contradicts ADR-030 D3 (`docs/archive/adr/ADR-030-sovereign-mesh-manifest-boundary.md:67`). | **[marker: no accepted record]** Forum members' messages (third-party content) and the agent's context. Credentials `ANTHROPIC_API_KEY` / `ZAI_ANTHROPIC_API_KEY`: devuser-class (Q8). | +| `dream-forum-suggestions` | Dream forum-suggestions tenant (`scripts/dream-forum-suggestions.mjs`) | prompt, public artefact | vendor: Z.AI triage (`scripts/dream-forum-suggestions.mjs:57`); replies to the forum relay (`scripts/dream-forum-suggestions.mjs:48-49`) | `DREAM_FORUM_SUGGESTIONS=0` (`scripts/dream-machine-nightly.mjs:700`); clarifying DMs held while `[sovereign_mesh].junkiejarvis` is off (`scripts/dream-forum-suggestions.mjs:220`) | ADR-2088 (accepted) covers the clarifying DMs. None for the Z.AI triage hop. | **[marker: no accepted record]** Forum suggestion text goes to Z.AI. Replies are posted in-thread as JunkieJarvis. | +| `dream-digest` | Nightly dream digest forum post (`services/dream-engine/src/digest.rs`) | public artefact | public internet: Forum relay, `DREAM_RELAY` else the cloud default (`services/dream-engine/src/relay.rs:32-38`) | `DREAM_DIGEST=0` (`services/dream-engine/src/engine.rs:312`) | none. | **[marker: no accepted record]** Kind-42 summary of the night. Encrypted to the zone key or refused, never plaintext in an encrypted zone (`services/dream-engine/src/digest.rs:412-424`). | +| `dream-governance` | Dream governance panel and cases, kinds 31400/31402 (`services/dream-engine/src/governance.rs`) | public artefact | public internet: Forum relay (`services/dream-engine/src/governance.rs:9-11`) | `DREAM_GOVERNANCE=0` (`services/dream-engine/src/governance.rs:82-83`) | none. | **[marker: no accepted record]** One case per open inbox item, signed by JunkieJarvis. Admin replies (31403) are ingested the next night. | +| `exp-b8-report` | EXP-B8 stop report and gate-flip PR (`scripts/experiments/exp-b8-label-log.cjs`) | public artefact, code | public internet: Forum relay (`scripts/experiments/exp-b8-label-log.cjs:445`); a GitHub PR | `[skills.routing].label_log` (`agentbox.toml:992`); `--dry-run` never posts | Owner decision 2026-10-02 R5b: report on the forum when the stopping rule fires (`scripts/experiments/exp-b8-label-log.cjs:7-8`). | One kind-42 summary, zone-encrypted or refused (`scripts/experiments/exp-b8-label-log.cjs:548`), at most once. | +| `sidestr-announce` | sidestr producer tips, kind 33333 (`config/sidechain/run-producer.sh`) | public artefact | public internet: Five public relays (`config/sidechain/run-producer.sh:49`) | `[sidechain].enabled` (`agentbox.toml:1573`), rebuild-class | none accepted. ADR-2098 registers kind 33333 but is `proposed`. | **[marker: no accepted record]** Public chain data, not a prompt. Recorded for completeness. Signer key under `/var/lib/agentbox/secrets`. | +| `sidestr-pages` | sidestr Pages mirror (`config/sidechain/mirror-sync.sh`) | public artefact | public internet: GitHub Pages checkout, `git push` (`config/sidechain/mirror-sync.sh:81`); announced URL `agentbox.toml:1574` | `[sidechain].mirror` (`agentbox.toml:1575`), rebuild-class | none accepted. ADR-2098 and ADR-2103 are `proposed`. | **[marker: no accepted record]** Block files only. Uses the devuser `gh` credential (`flake.nix:2692`). | +| `github` | `gh` and `git push` from agent sessions | code, public artefact | vendor: GitHub | none | none. | **[marker: no gate]** **[marker: no accepted record]** Every agent can push via the `gh` credential helper (`config/entrypoint-unified.sh:388-391`). Credential `GITHUB_TOKEN` (`flake.nix:3433`): devuser-class (Q8). Private repositories land in vendor custody; public ones are published. | +| `crates-io` | `cargo publish` | public artefact, code | public internet: crates.io | none. The credential is written whenever `CRATES_TOKEN` is set (`config/entrypoint-unified.sh:3072-3077`). | ADR-2106 (accepted) for `sidestr-*` crates only, each publication approved by the owner case by case (`docs/adr/ADR-2106-sidestr-crates-are-agpl-derivatives-of-siding-published-and-consumed.md:36-47`). Any other crate has none. | **[marker: no gate]** Publication is irreversible (yank only hides a version). Credential `CRATES_TOKEN`: devuser-class (Q8). | +| `cloudflare` | `wrangler` (Workers deploy, D1, `secret put`) and the forum backup cron | code, secret-adjacent | vendor: Cloudflare API | none. `wrangler` is always baked (`flake.nix:531-533`); the backup cron is unconditional (`flake.nix:2643-2649`). | none. | **[marker: no gate]** **[marker: no accepted record]** `wrangler secret put` sends secret values by design. The backup pulls data in but presents the token. Credentials `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`: devuser-class (Q8). | +| `tailscale` | `tailscaled` (`flake.nix:2447-2450`) | secret-adjacent | vendor: Tailscale coordination plane; peers on the tailnet | `[networking].tailscale` (`agentbox.toml:282`), currently `true` | none. ADR-007 (accepted, archived) only covers `tailscaled` running as root; ADR-045 is `proposed`. | **[marker: no accepted record]** Node identity and metadata go to the vendor. Tailnet traffic bypasses the did:nostr boundary (`agentbox.toml:266-272`). The manifest comment says off by default (`agentbox.toml:277-281`) but the value is `true`. Credential `TAILSCALE_AUTHKEY`: devuser-class (Q8). | +| `mcp-research` | Research MCPs: `web-researcher`, `perplexity` (MCP hub) | prompt | vendor: Search and answer vendors (Perplexity and the web-researcher backends) | `[skills.research].web_researcher` (`agentbox.toml:581`), rebuild-class; `perplexity` sits in the MCP hub list (`agentbox.toml:1269-1272`) with no runtime switch | none. | **[marker: no accepted record]** Search queries written by agents, often paraphrasing the task. Credential `PERPLEXITY_API_KEY`: devuser-class (Q8). | + +Marked with no gate (5): `primary-model`, `loom-raw`, `github`, `crates-io`, `cloudflare`. + +Marked with no accepted-egress record (18): `consultant-codex`, `consultant-antigravity`, `consultant-zai`, `consultant-perplexity`, `consultant-deepseek`, `zai-night`, `ontology-monitor`, `nostr-gateway`, `junkiejarvis-llm`, `dream-forum-suggestions`, `dream-digest`, `dream-governance`, `sidestr-announce`, `sidestr-pages`, `github`, `cloudflare`, `tailscale`, `mcp-research`. + + + +## Environment classes — 2026-10-03 (custody X-1 step 1, W2) + +Bypass 3 of the role-isolation design: compose `env_file` puts `.env` into PID 1, supervisord +hands PID 1's environment to every child, and `identity.env` added `AGENTBOX_NSEC` on top. So +every agent shell, MCP server and hook carried the sovereign key, the JunkieJarvis key and the +break-glass bearer. An agent that ran `env` copied them into a transcript, and the transcript +went to a model provider. + +[`config/custody/env-classes.json`](../config/custody/env-classes.json) now names every +environment variable that the boot path and the role-secret consumers read. It lists names +only, never values. Each name is in exactly one class. +`node scripts/ci/env-secret-inventory.js --check` fails CI if a name read in code is +unclassified, if a name is in two classes, or if the table drifts from the entrypoint's scrub +list or from [ADR-2122](adr/ADR-2122-role-service-accounts-run-secrets-and-the-identity-port.md)'s +delivery plan (`config/role-accounts.json`). + +| Class | Count | Members | Why | +|---|---|---|---| +| **ROLE** | 11 | `AGENTBOX_PRIVKEY_HEX`, `AGENTBOX_NSEC` and `AGENTBOX_BRIDGE_SK` (the sovereign key under three names); `OPERATOR_NOSTR_PRIVKEY`; `JUNKIEJARVIS_PRIVKEY_HEX` and `CONCIERGE_PRIVKEY_HEX`; `AGENTBOX_AGENT_PRIVKEY_HEX` and `AGENT_PRIVKEY_HEX` (per-agent keys), all `ab-identity`. `NIP98_PROXY_ALLOW_BEARER` and `NIP98_PROXY_SESSION_SECRET` (`ab-ingress`). `TAILSCALE_AUTHKEY` (`root`). | A signing, join or session key belongs to one role. Under `[security].role_isolation` the entrypoint writes it to `/run/secrets//` (`0400`) and unsets it from PID 1 before the identity bootstrap. `identity.env` becomes public-only, and a final scrub before `exec supervisord` logs and removes any that reappear (`ROLE-ISOLATION-LEAK `). Readers take `_FILE` (or `$AGENTBOX_SECRETS_DIR/`). Under the flag they ignore and report the bare variable. | +| **DEVUSER_CLASS** | 35 | `BRIDGE_TOKEN`; `MANAGEMENT_API_KEY`, `AGENTBOX_ACTION_PIPELINE_SECRET`, `WEBHOOK_HMAC_SECRET`, `SOLID_ADMIN_KEY` and `VISIONCLAW_AGENT_KEY` (management-api); `VAULT_NOSTR_SECRET`; the Anthropic, OpenAI, Gemini/Google, DeepSeek, OpenRouter, Perplexity, Brave, Context7, Hugging Face, Z.AI, TypeSafe, System-One, JunkieJarvis-LLM and email-gateway keys; GitHub, crates.io and Cloudflare tunnel tokens; the Postgres/RuVector password and connection string; code-server and Jupyter logins (and two entrypoint-local copies). | **Accepted exceptions** (queen's disposition Q8, pending owner). The agents are these credentials' users, and their processes run as devuser. Each row in the table carries its reason. Two are flagged. `VAULT_NOSTR_SECRET` is a signing key whose only consumer, the external vault CLI, has no file input yet, so it is a ROLE candidate for W3b. `BRIDGE_TOKEN` is below. | +| **NON_SECRET** | ~600 | Paths, ports, switches, model names, public keys, and shell locals. | Nothing to protect. A credential-shaped name can never land here by pattern; it needs an exact entry. | + +**The break-glass double, recorded 2026-10-03.** `voice up` copies `BRIDGE_TOKEN` into +`NIP98_PROXY_ALLOW_BEARER` (`management-api/lib/system-manifest.js:79`). The proxy's copy is +ROLE and leaves PID 1 under the flag. `BRIDGE_TOKEN` stays devuser-class because tab0-bridge, +which drives tmux as devuser, needs it. So the LAN-door break-glass credential stays reachable +by devuser until Q4 splits the bearer. The lifecycle belongs to +[ADR-2027](adr/ADR-2027-secret-custody-rotation-break-glass.md) (proposed); this step does not +redesign it. + +**What the flag does and does not buy.** The flag ships off, and with it off the environment +handed to supervisord is byte-identical (`tests/runtime-contract/RC-X1-06.sh`). With it on: + +- ROLE values are gone from every process's environment and from `tailscale-up`'s argv. That + closes the transcript and crash-dump leak. +- Until W3's identity port exists, the devuser readers of `ab-identity` files fail closed: + JunkieJarvis does not start, dream-engine's forum posts stop, and the live mirror seals under + a throwaway key. Do not turn the flag on before W3b. +- DEVUSER_CLASS credentials are unchanged by design. +- The repo `.env` remains readable on the workspace bind (Q13, host-side). + +## Role isolation — 2026-10-03 (custody X-1 step 1, staged) + +Decision: [ADR-2122](adr/ADR-2122-role-service-accounts-run-secrets-and-the-identity-port.md) +(proposed, `activation_status: inactive`). Citations are at `custody/integration` `d03defbea`. +Owner procedure: [Turning on role_isolation](developer/role-isolation-runbook.md). + +**Staged, not live.** Everything below is in the source on this branch. None of it runs in +an image until the owner rebuilds. None of it is enforced until the flag is on **and** +the two-half rehearsal has passed and landed its receipt. Until then this section describes what +the branch would enforce. It is not a claim about the running container. "Live" in this register +means a passing receipt, never a merge. + +### The switch + +`[security].role_isolation`, default `false` (`agentbox.toml:2112`, +`setup/agentbox.default.toml:1625`, catalogue `management-api/lib/system-manifest.js:94-96`, +apply class `boot`). The entrypoint reads it once and exports it +(`config/entrypoint-unified.sh:343-361`). The image must carry the custody library, both +supervisor configs and the delivery plan. An image without them logs +`ROLE-ISOLATION-UNAVAILABLE` and boots as if the flag were off (`:352-359`). The first image +built from this branch needs one `./agentbox.sh rebuild`. After that, flipping the flag needs +only a restart. + +**With the flag off, the boot is today's**, with one exception: `/run/secrets` becomes its own +tmpfs, which devuser cannot rename. The entrypoint still chowns it to devuser `0700` and writes +the devuser `nostr.key` as before (`config/entrypoint-unified.sh:388-391`, `:1020`). The +environment that reaches supervisord is byte-identical (`tests/runtime-contract/RC-X1-06.sh`). + +### The role table + +Single source: `config/role-accounts.json`. It is baked into `/etc/passwd`, `/etc/group` and +`/etc/agentbox/role-accounts.json` (`flake.nix:3571-3577`). Each role has its own primary group +with no members, shell `/sbin/nologin` and home `/run/secrets//home`. uid 965 is skipped: +it is the host docker group (`reserved_ids`). + +| Role | uid | Programs under the flag | Secrets in `/run/secrets//` | +|---|---|---|---| +| `ab-identity` | 960 | `nostr-relay`, `serve-identity` | `nostr.key`, `AGENTBOX_PRIVKEY_HEX`, `AGENTBOX_NSEC`, `JUNKIEJARVIS_PRIVKEY_HEX`, `CONCIERGE_PRIVKEY_HEX` (from PID 1's environment) | +| `ab-gateway` | 961 | `nostr-gateway` | none; it signs through the identity port | +| `ab-ingress` | 962 | `nip98-proxy` | `NIP98_PROXY_ALLOW_BEARER`, `NIP98_PROXY_SESSION_SECRET` (from PID 1's environment) | +| `ab-spend` | 963 | none (account only; Q6 deferred the spend port) | none | +| `ab-sidestr-dreamlab` | 964 | `sidestr-producer` | `signer.key`, `parent.credential` (copied from `/var/lib/agentbox/secrets`) | +| `ab-faucet-dreamlab` | 966 | `sidestr-faucet` | `treasury.key` (copied from the workspace path the manifest names) | +| `ab-sidestr-dreamlab-txbt4` | 967 | `sidestr-producer-dreamlab-txbt4` | `signer.key`, `parent.credential` | +| `ab-faucet-dreamlab-txbt4` | 968 | `sidestr-faucet-dreamlab-txbt4` | `treasury.key` | +| group `ab-identity-port` | 969 | — | members devuser, `ab-identity`, `ab-gateway`; owns the port socket's directory | + +management-api, dream-engine, aoe, tmux and the agents stay devuser. JunkieJarvis is a key held +by `ab-identity`, not an account. + +### Two supervisor configs + +`/etc/supervisord.conf` is today's config. `/etc/supervisord.roles.conf` is derived from it by +`agentbox-manifest role-accounts isolate` (`flake.nix:3571-3576`). In each role program the +derivation changes only the `user=` and `environment=` lines, and +`tests/config/role-isolation-supervisor.test.sh` fails on any other difference. The build itself +fails if a secret-bearing program has no role or a role program is not `user=devuser` in today's +config. The entrypoint picks one +config at its final `exec` (`config/entrypoint-unified.sh:1142-1160`). + +### `/run/secrets` + +- **The mount.** In both modes `/run/secrets` is its own tmpfs, + `mode=711,uid=0,gid=0,noexec,nosuid,nodev` (`docker-compose.yml:110`, `flake.nix:3278`). + Because it is a mount point, devuser, who owns `/run`, cannot rename it. +- **Delivery.** Under the flag the root phase delivers each role's files just before `exec` + (`ab_role_secrets_deliver`, `config/lib/role-custody.sh:160`). Each role directory is + `0500` and each file `0400`, owned by the role. A file source is copied only if it is a + regular file, not a symlink, and at most 64 KiB. +- **The state file.** The result, `ok:` or `degraded:` for `secrets-mount`, + `secrets-delivery` and `docker-socket`, goes to `/run/secrets/role-isolation.state`. +- **Failure.** A failure never stops the boot. A role whose secret is missing fails closed + under supervisord. + +### The environment classes + +The ROLE / DEVUSER_CLASS / NON_SECRET split is the section above ("Environment classes"). Under +the flag: + +1. ROLE variables are captured to files and unset before the identity bootstrap + (`config/entrypoint-unified.sh:489`). +2. `identity.env` is public-only (`services/nostr-pod-bridge/src/bootstrap.rs:230`). +3. A last scrub before `exec` logs `ROLE-ISOLATION-LEAK ` for anything that reappears + (`config/entrypoint-unified.sh:1158`). + +DEVUSER_CLASS credentials, including `BRIDGE_TOKEN` and the provider keys, stay in devuser's +environment by design. + +### The identity port + +`nostr-pod-bridge serve-identity` (`[program:serve-identity]`, `flake.nix:2515-2529`) runs as +`ab-identity` under the flag. With the flag off it prints one line and exits 0, so `EXITED` is +its expected status. It serves `/run/secrets/ab-identity-port/identity.sock` (`0660`, group +`ab-identity-port`) and admits callers by `SO_PEERCRED` uid against +`config/custody/identity-port-acl.json`: devuser (1000) and `ab-gateway` (961). + +Its operations are a closed list (`services/nostr-pod-bridge/src/identity_port/acl.rs:43`): +`pubkey`, `nip98`, `sign_event` (granted kinds only), `forum_event`, `nip42_auth` and +`mirror_key`. There is no generic sign operation (ADR-2101). Every decision appends one +content-free line under `/var/lib/agentbox/events/sign/`. + +The pods signer uses the port when the flag is on (`management-api/lib/pod-signer.js:131`). +JunkieJarvis, the mirror hook, the gateway and dream-engine are not yet cut over (W3b). + +### Docker under the flag + +The entrypoint does not widen `/var/run/docker.sock` for devuser +(`config/entrypoint-unified.sh:586-612`). devuser's `DOCKER_HOST` points at the GET-only +`[program:docker-read-proxy]`, `/run/docker-ro.sock` (`flake.nix:2493`; +`config/entrypoint-unified.sh:3252-3254`). `ps`, `logs`, `inspect`, `version` and `info` +pass. `exec`, `run`, `create`, `cp` and `export` get 403. + +If the host socket is still world-writable, the boot records `degraded:docker-socket`. The fix +is host-side (Q2): `chmod 0660 /var/run/docker.sock` on the host. + +### What is still open (the flag gives less than its name until these land) + +- **At-rest custody (W2 remainder).** No migrate step on this branch chowns + `/var/lib/agentbox/secrets`, the identities volume or the workspace treasury keys to root. + devuser can still read the at-rest copies. The flag protects only the delivered runtime + copies and the environment. +- **Consumers (W3b).** JunkieJarvis, dream-engine's forum posts, the live-mirror hook and the + gateway still read keys that leave the environment under the flag, so they fail closed. +- **Producer state and checkouts (W4).** Chain state is still under `$WORKSPACE/sidestr/`. + The producer's code is the Nix bake of `config/sidechain/upstream-pins` in both modes + (custody W5, `config/sidechain/run-producer.sh:64-89`), and that is also staged until the + rebuild. +- **The AoE share and `role-exec` (W2).** `ab-gateway` and `ab-ingress` still point at + devuser's `serve.url`. There is no `role-exec` wrapper yet. +- **The break-glass double (Q4).** `NIP98_PROXY_ALLOW_BEARER` equals `BRIDGE_TOKEN`, which + stays devuser-class. +- **The repo `.env`.** It remains the at-rest home of the ROLE values and is readable on the + workspace bind (Q13, host-side). The flag moves those values out of the process + environment. It does not move them out of `.env`. + +The register rows of 2026-09-04 and 2026-09-05 above are unchanged by this section. The bridge +key's interface there (`/run/secrets/nostr.key`) is still the flag-off path. Under the flag, +the same key is `/run/secrets/ab-identity/nostr.key`. diff --git a/docs/adr/ADR-2002-aoe-token-auth-boundary.md b/docs/adr/ADR-2002-aoe-token-auth-boundary.md index d9eecd654..aa1145739 100644 --- a/docs/adr/ADR-2002-aoe-token-auth-boundary.md +++ b/docs/adr/ADR-2002-aoe-token-auth-boundary.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: f7465412de3d0d7a25fc1b6b2c8a72775490616d +verified_commit: dc91e092ab646b4a825805b8229602ac8b15bad3 verified_paths: [config/nip98-proxy/proxy.mjs, scripts/aoe-curl.sh, flake.nix] owner: jjohare review_trigger: next image rebuild (activation), or any new consumer of :9095, or per-process isolation becoming available @@ -204,3 +204,29 @@ Tripped by the `sidestr:dreamlab-txbt4` seal. `flake.nix` adds `sidechainChains` - the manifest validator is valid; - `check-manifest-catalogue` passes; - `tests/config/sidechain-genesis.test.sh` passes 7/7 and `sidechain-producer-gates.test.sh` 7/7. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `[program:aoe-serve]` and its `--auth token` are untouched, so the decision holds. Under the flag, `nostr-gateway` and `nip98-proxy` run as `ab-gateway` and `ab-ingress` and can no longer read devuser's 0600 `serve.url`. They fail closed (no token, no drive) until the aoe-share copy (custody design §2.6, W2) exists, which strengthens this record rather than weakening it. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/nip98-proxy/proxy.mjs` changes only how it reads `NIP98_PROXY_ALLOW_BEARER` and `NIP98_PROXY_SESSION_SECRET` (through `management-api/lib/role-secret.js`; flag off the same read). The AoE token path (`AOE_TOKEN_FILE`, `serve.url`, the forwarded bearer) is untouched. `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +The AoE token boundary is untouched: no AoE, interaction-plane or token code is in the range. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. diff --git a/docs/adr/ADR-2009-nip98-proxy-identity-boundary.md b/docs/adr/ADR-2009-nip98-proxy-identity-boundary.md index 7cb2cb9a5..4b2079697 100644 --- a/docs/adr/ADR-2009-nip98-proxy-identity-boundary.md +++ b/docs/adr/ADR-2009-nip98-proxy-identity-boundary.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: f7465412de3d0d7a25fc1b6b2c8a72775490616d +verified_commit: dc91e092ab646b4a825805b8229602ac8b15bad3 verified_paths: [config/nip98-proxy/proxy.mjs, flake.nix, docs/INGRESS-identity.md] owner: jjohare review_trigger: A second identity ingress is proposed, or aoe serve stops binding loopback @@ -212,3 +212,29 @@ Tripped by the `sidestr:dreamlab-txbt4` seal. `flake.nix` adds `sidechainChains` - the manifest validator is valid; - `check-manifest-catalogue` passes; - `tests/config/sidechain-genesis.test.sh` passes 7/7 and `sidechain-producer-gates.test.sh` 7/7. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `[program:nip98-proxy]` is unchanged in `supervisord.conf`. Under the flag it runs as `ab-ingress`. `NIP98_PROXY_ALLOW_BEARER` and `NIP98_PROXY_SESSION_SECRET` are delivered to `/run/secrets/ab-ingress/` and removed from PID 1, and `proxy.mjs` reads them from the environment only. With the flag on, break-glass is therefore off and sessions use a per-boot secret (`proxy.mjs:212`). Both are fail-closed outcomes, so the boundary holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/nip98-proxy/proxy.mjs` now reads `NIP98_PROXY_ALLOW_BEARER` and `NIP98_PROXY_SESSION_SECRET` through `management-api/lib/role-secret.js` (`_FILE`, then `$AGENTBOX_SECRETS_DIR/`, then the bare variable only with the flag off). Flag off it is the same read (the nip98 selftest passes unchanged). NIP-98 verification, the identity headers and the routes are untouched, so the decision holds. `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +nip98-proxy's fail-closed boundary is untouched by these lines. Its secret loading moved to the shared role-secret loader in W2 (ADR-2122 §3a), which fails closed when the loader is unavailable under the flag. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. diff --git a/docs/adr/ADR-2010-bearer-gated-behind-nip98.md b/docs/adr/ADR-2010-bearer-gated-behind-nip98.md index c756dfbad..cac9d8c9b 100644 --- a/docs/adr/ADR-2010-bearer-gated-behind-nip98.md +++ b/docs/adr/ADR-2010-bearer-gated-behind-nip98.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: a48ea407a24185f7a4f654a35e66805778acbec8 +verified_commit: 275e12356319a9630846656580d497d53de3d38c verified_paths: [config/nip98-proxy/proxy.mjs, docs/INGRESS-identity.md] owner: jjohare review_trigger: A governance upstream stops re-verifying the operator signature, or a bearer is added to the default AoE route @@ -136,3 +136,8 @@ Tripped by `docs/INGRESS-identity.md` gaining item 10 (encrypted forum zones, fo Tripped by ADR-2078 (pods signer signs as the sovereign identity). `docs/INGRESS-identity.md` gains invariant 11 (pod origination), a Remediation — 2026-10-02 entry, a superseded-by marker on the 2026-09-05 `sign_requests = false` line and changelog 0.2.2; invariants 1-10 are unchanged. It touches no section, key or phase this record governs, and the decision holds unchanged. Re-verified by `git diff f63760e19..a48ea407a -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/nip98-proxy/proxy.mjs` reads the break-glass bearer through `management-api/lib/role-secret.js`. Flag off it is the same read; under the flag it comes from the ab-ingress file W1 delivers and a bare variable is ignored and logged `ROLE-ISOLATION-LEAK`. The per-route bearer gating behind a verified NIP-98 identity is untouched. Recorded fact: the bearer is still the same value as `BRIDGE_TOKEN`, which stays devuser-class (`config/custody/env-classes.json`), so it stays devuser-reachable until the Q4 split (ADR-2027). The decision holds. +Re-verified by `git diff a48ea407a..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. diff --git a/docs/adr/ADR-2011-hex-canonical-identity.md b/docs/adr/ADR-2011-hex-canonical-identity.md index 53f5e8345..0d1676b46 100644 --- a/docs/adr/ADR-2011-hex-canonical-identity.md +++ b/docs/adr/ADR-2011-hex-canonical-identity.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: a48ea407a24185f7a4f654a35e66805778acbec8 +verified_commit: 275e12356319a9630846656580d497d53de3d38c verified_paths: [management-api/lib/agent-identity.js, config/nip98-proxy/proxy.mjs] owner: jjohare review_trigger: A durable identity appears in bech32/npub form in storage or a URL, or the did:nostr:local fallback fires in production @@ -114,3 +114,8 @@ Only `config/nip98-proxy/proxy.mjs` moved (`de37998a8`, `cae729aa7`); `managemen Tripped by ADR-2078 (pods signer signs as the sovereign identity). `management-api/lib/agent-identity.js` gains `sovereignIdentityPath` and `loadSovereignSigner`, a read-only reader of the bootstrap identity that keys on the 64-hex x-only pubkey, returns `did:nostr:`, and returns only `pubkey`, `did`, `path` and a `sign` closure, never the secret; `loadOrMint`, `deriveXonly`, `multikeyFromXonly` and the CLI mint are unchanged. That extends the record rather than departing from it: the new reader keeps the canonical-hex identity and the private-key-stays-inside rule. The decision holds unchanged. Re-verified by `git diff b680a7aee..a48ea407a -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `management-api/lib/agent-identity.js` now re-exports the ROLE-secret loader and reads the `AGENTBOX_AGENT_PRIVKEY_HEX` override through it (`_FILE` first; the bare variable only with the flag off). Derivation, the x-only hex identity and the DID string are unchanged. `config/nip98-proxy/proxy.mjs` changes only how its two secrets are read; pubkeys stay 64-hex x-only. The decision holds. +Re-verified by `git diff a48ea407a..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. diff --git a/docs/adr/ADR-2012-relay-allowlist-only-ingress.md b/docs/adr/ADR-2012-relay-allowlist-only-ingress.md index 1abaa1820..efd1fd731 100644 --- a/docs/adr/ADR-2012-relay-allowlist-only-ingress.md +++ b/docs/adr/ADR-2012-relay-allowlist-only-ingress.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [agentbox.toml, flake.nix] owner: jjohare review_trigger: ingress_policy changes from allowlist, or the ADR-040 D3 governance-publisher key-split lands @@ -272,3 +272,37 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port. Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. `AGENTBOX_ALLOWED_PUBKEYS` and the relay's no-fallback admission are byte-identical in both supervisor configs (`tests/config/role-isolation-supervisor.test.sh`). The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. This record's claim at `:157-160` needs one correction and one confirmation. The correction: the entry it calls `visionclaw-server (governance publisher)` is the operator's decision signer. Q15 evidence, read from the dreamlab relay (every stored event of kinds 31400-31403) and from VisionClaw source: VisionClaw's 31402s are signed by `11ed6422…663c`, and `b41654017f…` signs only 31403. The confirmation: the ADR-040 D3 / G-5 key split has **not** landed, because no new pubkey is admitted yet, so `review_trigger` has not fired. The inline annotation now names G-5 rather than ADR-040 D3. `flake.nix` is untouched, so the baked allowlist is byte-identical. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `agentbox.toml` changed only in the trailing comments of two allowlist entries (`d3ff8e9a8`, the Q15 correction: `b4165401` is the operator's decision signer). `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +The relay allowlist is unchanged: no allowlist entry was added or removed, and the relay still admits only listed pubkeys. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. The relay allowlist and its ingress are untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2013-loopback-publish-except-9096.md b/docs/adr/ADR-2013-loopback-publish-except-9096.md index c993501ca..a76d663f0 100644 --- a/docs/adr/ADR-2013-loopback-publish-except-9096.md +++ b/docs/adr/ADR-2013-loopback-publish-except-9096.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: e434a7a596a3a0518c51b7da107d6e0831891910 +verified_commit: dc91e092ab646b4a825805b8229602ac8b15bad3 verified_paths: [scripts/ci/check-ports-loopback.sh, .github/workflows/invariants.yml, flake.nix, docker-compose.yml] owner: jjohare review_trigger: Any new entry on the SANCTIONED list, or a new compose overlay file @@ -18,6 +18,10 @@ lineage: legacy ADR-045 (sovereign ingress front door), R-003 (loopback publish # ADR-2013 — Loopback-only compose publishes except the sanctioned-exposure list +## Re-verification — 2026-10-03 at a10f336fc (prompt egress register, N-7) + +`a10f336fc` touched the governed `.github/workflows/invariants.yml` without touching this decision: it adds `docs/SECURITY-profiles.md`, `config/egress-policy.json` and `docs/LAN-door-threat-model.md` to the push paths and one step, `check-egress-register`, after `check-adr-index`. The `check-ports-loopback` step this record relies on is unchanged (`.github/workflows/invariants.yml:63-64`). No publish, SANCTIONED entry or overlay changed. Status axes unchanged. + ## Re-verification — 2026-10-02 at caab741c6 (factrail landing, ADR-2121) The factrail landing (ADR-2121, `b7fc2b0e5` + `caab741c6`) touched governed paths without touching this decision: `.github/workflows/invariants.yml` adds the ADR-ratchet self-test step and renames the ratchet step; `flake.nix` gains only the factrail package, its `/opt/agentbox/bin/factrail` link and the shim copy, each under `lib.optionalString jevCompactionOn`. No hunk falls in code this record governs, so its claims and status axes stand unchanged. @@ -281,3 +285,29 @@ Tripped by the `sidestr:dreamlab-txbt4` seal. `flake.nix` adds `sidechainChains` ## Re-verification — 2026-10-02 (`e434a7a596a3a0518c51b7da107d6e0831891910`) Tripped by the sidechain health and witness change. `.github/workflows/invariants.yml` gains the CY-A2 `check-declared-vs-running` step, its unit tests and two trigger paths, none of them about exposure. The check-ports-loopback and check-listeners steps are unchanged. `sh scripts/ci/check-ports-loopback.sh` exits 0. Decision and status unchanged. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `docker-compose.yml` gains one tmpfs line (`/run/secrets`) and no `ports:` entry. The read proxy is a unix socket. No publish changes, so the decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `.github/workflows/invariants.yml` gains the env-secret-inventory check, its unit tests and RC-X1-06; no existing step changes. `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `.github/workflows/invariants.yml` gains the `check-egress-register` step (`a10f336fc`, N-7). `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +No compose publish is added or widened; `check-ports-loopback.sh` passes. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `.github/workflows/invariants.yml` (`e9f5cd6da`, `dc91e092a`) adds the RC-X1-01..05 and -07 steps and the compose-role-env step, widens its path filter, and corrects one step label; no existing step changes; `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. diff --git a/docs/adr/ADR-2019-model-lifecycle-384-dim-freeze.md b/docs/adr/ADR-2019-model-lifecycle-384-dim-freeze.md index fba5dcc9c..22df77530 100644 --- a/docs/adr/ADR-2019-model-lifecycle-384-dim-freeze.md +++ b/docs/adr/ADR-2019-model-lifecycle-384-dim-freeze.md @@ -7,7 +7,7 @@ implementation_status: none activation_status: inactive supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [mcp/servers/lib/aggregate-effectiveness.js, scripts/ruvector-sona-feeder.mjs, agentbox.toml] owner: jjohare review_trigger: A SONA binary with configurable embedding_dim (384-capable) ships, or a dimension migration is planned @@ -207,3 +207,24 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port. Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. Nothing this record governs is touched, and the decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `d3ff8e9a8` the governed paths changed as follows. `agentbox.toml` gains `[security].role_isolation = false` with its comment (`49961f88c`, ADR-2122); no other key changes. +No model, embedding dimension, SONA or attention-rerank setting changes. The decision holds. Re-verified by `git log d3ff8e9a8..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. The embedding model, the 384-dim column and the SONA and attention-rerank switches are untouched. The decision holds. Re-verified by `git log 3b5412963..f93586b9e -- `. diff --git a/docs/adr/ADR-2020-capability-gating.md b/docs/adr/ADR-2020-capability-gating.md index cbcacdc30..8bd173350 100644 --- a/docs/adr/ADR-2020-capability-gating.md +++ b/docs/adr/ADR-2020-capability-gating.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [agentbox.toml, skills/tree-search-coder/SKILL.md, services/agentbox-ops/src/bin/tree-search-cap.rs] owner: jjohare review_trigger: any new optional skill/feature block added to agentbox.toml, or any change to the tree-search-coder spend/route posture @@ -214,3 +214,24 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port. The block still adds no package or supervised program, stays capped, and is explicitly invoked only. Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. This is a new gate, so the byte-identical-when-off re-check is due. Result: **identical in effect, not identical in bytes**, the shape already recorded here for ADR-2094. With the flag off the boot executes today's statements and `exec supervisord -c /etc/supervisord.conf` (`tests/config/role-isolation-boot.test.sh`). The image does gain inert role passwd and group lines, `/etc/supervisord.roles.conf`, `/etc/agentbox/role-secrets.tsv`, `/etc/agentbox/role-accounts.json` and a root-owned `/run/secrets` tmpfs. The tmpfs is the one change visible in both modes: devuser can no longer rename `/run/secrets`. The flag is boot-class by design (ADR-2122), so these ship ungated. `implementation_status` stays `partial` for the reason already recorded. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `d3ff8e9a8` the governed paths changed as follows. `agentbox.toml` gains `[security].role_isolation = false` with its comment (`49961f88c`, ADR-2122); no other key changes. +The new key defaults to off. Off, the boot is today's, which `tests/config/role-isolation-boot.test.sh` shows. It is identical in effect, in the record's sense. The decision holds. Re-verified by `git log d3ff8e9a8..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. No capability gate or spend cap moves. The decision holds. Re-verified by `git log 3b5412963..f93586b9e -- `. diff --git a/docs/adr/ADR-2023-loom-facade.md b/docs/adr/ADR-2023-loom-facade.md index 8b9af3110..3bd3e6070 100644 --- a/docs/adr/ADR-2023-loom-facade.md +++ b/docs/adr/ADR-2023-loom-facade.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [agentbox.toml, mcp/servers/lib/ontology-retrieval.js] owner: jjohare review_trigger: model swap behind the Loom, or ADR-051 deferred-distillation MCP tools becoming a discrete server @@ -277,3 +277,24 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port. Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. `[skills.ontology.condense]` and the Loom door are untouched, and the decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `d3ff8e9a8` the governed paths changed as follows. `agentbox.toml` gains `[security].role_isolation = false` with its comment (`49961f88c`, ADR-2122); no other key changes. +No Loom URL or consumer of the :8084 door changes. The decision holds. Re-verified by `git log d3ff8e9a8..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. The Loom door and the model URL behind it are untouched. The decision holds. Re-verified by `git log 3b5412963..f93586b9e -- `. diff --git a/docs/adr/ADR-2026-session-mirror-egress-boundary.md b/docs/adr/ADR-2026-session-mirror-egress-boundary.md index 5c3c8d8ed..16da45ba9 100644 --- a/docs/adr/ADR-2026-session-mirror-egress-boundary.md +++ b/docs/adr/ADR-2026-session-mirror-egress-boundary.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: inactive supersedes: [] superseded_by: [] -verified_commit: 526b97dc6752ceaa9889ed3cd199b5145b7cf94c +verified_commit: 3b54129631067277f6363309b01cce485faa027a verified_paths: [config/egress-policy.json, config/hooks/lib/egress-policy.cjs, config/hooks/nostr-live-mirror.cjs, tests/sovereign/egress-boundary.test.js] owner: jjohare review_trigger: any change to config/hooks/nostr-live-mirror.cjs or the mobile_bridge digest, or the recipient/relay configuration @@ -17,6 +17,10 @@ domain: SECURITY-profiles # ADR-2026 — Session-mirror cloud egress boundary +## Re-verification — 2026-10-03 at a10f336fc (prompt egress register, N-7) + +`a10f336fc` touched the governed `config/egress-policy.json` without touching this decision. It adds a top-level `register` key (31 routes, rendered into `docs/SECURITY-profiles.md` by `scripts/ci/render-egress-register.js`), and changes no byte of `paths`, `switches`, `invariants` or `outcomes` beyond the comma that separates the new key. The register cites this record as `proposed` for the `live-mirror` and `session-digest` rows, beside their accepted predecessors (ADR-029 D3, ADR-030 D3). Test: `jest --roots=tests/sovereign --testPathPatterns egress-boundary` → 25 passed, including the assertion that `paths` holds exactly `live-mirror` and `session-digest`. Status axes unchanged. + ## Re-verification — 2026-09-29 (instruction-home migration) Stop-only mirroring narrows the default event set; explicit event overrides still pass through the existing off-switch, recipient and redaction checks. The isolated egress and mirror-provenance suites pass alongside the adapter contracts (613 tests, 31 suites). No real transcript or relay send was exercised; existing activation/status limitations remain. Verification anchor: `526b97dc6`. Status axes are unchanged by this source check. @@ -181,3 +185,16 @@ The source verification anchor for this execution is `a0ee1fe5740baa38e14c4ff3fe - **Priority:** P2 — next cycle (owner decision 2026-10-02, Q4; was P1, Track C item 10, fresh-host bring-up; row G-4) - **Why:** All four clauses have code and tests behind them (`config/hooks/lib/egress-policy.cjs`, `egress_policy.rs`, 25 egress-boundary tests; G-4 recipient enumeration 2026-09-07; `b25903ec8` checks the allowlist before any work). The mirror hook is registered on every turn in `~/.claude/settings.json`, and in this container no `AGENTBOX_MIRROR_RECIPIENTS` is set, so it skips. A deployment on Trust-owned hardware (§2 Track C, §9) has to show that nothing leaves the box by default. That is this record's clause (c), and it is the one thing the record has not demonstrated on a running instance. - **Next:** During the Track C fresh-host run, capture a receipt showing every egress path reports `skipped` with no allowlist configured. With that and the owner's reviewed recipient set (G-4), it is ready to accept. **Moved to P2 — owner decision 2026-10-02, Q4:** the Trust runbook will not include an egress-nothing-by-default receipt or name who holds each secret, so the Track C fresh-host run will not produce clause (c)'s receipt this cycle. The receipt step above stands for next cycle. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/hooks/nostr-live-mirror.cjs` takes the operator key from `config/hooks/lib/operator-key.cjs`. Flag off that is the pre-W2 `envFirst` read, unchanged, so the phone's mirror child key cannot move. Under the flag only `_FILE` counts. **Consequence under the flag with W1:** the files are `ab-identity` 0400, so the devuser hook gets no key and seals under a throwaway key with no child key (fail-open, as designed) until the identity port's `mirror_wrap` (W3a/W3b) exists. The egress gates (`AGENTBOX_LIVE_MIRROR`, `AGENTBOX_EGRESS`, `config/egress-policy.json`) are untouched. The decision holds. +Re-verified by `git diff 526b97dc6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `config/egress-policy.json` gains the `.register` catalogue of egress routes (`a10f336fc`, N-7); `paths`, the redaction contract, is unchanged. +The session mirror's egress boundary and its redaction paths are unchanged. The register only catalogues routes. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. diff --git a/docs/adr/ADR-2027-secret-custody-rotation-break-glass.md b/docs/adr/ADR-2027-secret-custody-rotation-break-glass.md index 9e8b33857..18887486d 100644 --- a/docs/adr/ADR-2027-secret-custody-rotation-break-glass.md +++ b/docs/adr/ADR-2027-secret-custody-rotation-break-glass.md @@ -225,3 +225,14 @@ against running services, not a commit to point at. - **Priority:** P2 — next cycle (confirmed by owner decision 2026-10-02, Q4; reopens before any real-value spend, the ADR-2103 P21 gate, planning cycle §10; X-1 step 1 and G-5 are the same work) - **Why:** Break-glass bounds and an age-encrypted backup shipped (selftest section N; `services/secret-backup`, packaged by `lib/secret-backup.nix` 2026-09-07). The lifecycle this record decides has not been built: custodians are unconfirmed, nothing has been rotated, the publisher key split is not done (G-5 blocked), and the shared-UID exposure is still open (X-1, N-6). The 2026-09-21 cycle names none of this, and §10 ties custody to the real-value gate. - **Next:** Confirm custodians in the `SECURITY-profiles.md` register, and land X-1 step 1 (per-role service accounts) together with the ADR-2101 key separation before any value-bearing chain. **Owner decision 2026-10-02, Q4:** the Trust runbook will not name who holds each secret, so confirming custodians is not a this-cycle step either. The whole record waits for next cycle. + +## Note — 2026-10-03: the break-glass bearer in the environment classes (custody W2) + +`config/custody/env-classes.json` classifies `NIP98_PROXY_ALLOW_BEARER` as ROLE (`ab-ingress`). +Under `[security].role_isolation` it leaves PID 1's environment, and nip98-proxy reads it from a +file. `BRIDGE_TOKEN` is classified DEVUSER_CLASS, with a dated exception that points here. It is +the same value (`management-api/lib/system-manifest.js:79`) and stays ambient for tab0-bridge, so +the break-glass credential remains devuser-reachable until the bearer is split (Q4). Nothing in +this record's lifecycle changed. See `docs/SECURITY-profiles.md`, "Environment classes — +2026-10-03". + diff --git a/docs/adr/ADR-2028-vault-manifest-path-authority.md b/docs/adr/ADR-2028-vault-manifest-path-authority.md index 71d3d2720..d7420e868 100644 --- a/docs/adr/ADR-2028-vault-manifest-path-authority.md +++ b/docs/adr/ADR-2028-vault-manifest-path-authority.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [agentbox.toml, setup/agentbox.default.toml, schema/agentbox.toml.schema.json, config/entrypoint-unified.sh, mcp/servers/lib/ontology-local.js, mcp/servers/lib/ontology-index-build.js, scripts/ontology-condense-scheduler.mjs, scripts/ontology-condense-refresh.sh, skills/podcast-knowledge-ingest/SKILL.md, skills/ontology-core/SKILL.md, skills/ontology-enrich/SKILL.md, skills/ontology-augment/SKILL.md, skills/web-summary/SKILL.md] owner: jjohare review_trigger: any new skill, MCP server, or supervised program that reads or writes authored markdown @@ -191,3 +191,33 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port; `schema/agentbox.toml.schema.json` changes only `payments.properties.sidestr` (`producer_url` optional, `mirror_url` added). Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). `setup/agentbox.default.toml` and the schema gain only `role_isolation`. `_ab_vault_resolve` and the `[vault]` resolution are unchanged and still run before any consumer. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. `setup/agentbox.default.toml` received the same comment correction on its `b41654017f…` relay entry and nothing else. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `agentbox.toml` changed only in the trailing comments of two allowlist entries (`d3ff8e9a8`, the Q15 correction: `b4165401` is the operator's decision signer). `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. `setup/agentbox.default.toml` changed only in the same allowlist comment (`d3ff8e9a8`). +`[vault]` stays the single path authority; no vault path or consumer changes. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. `[vault]` and `_ab_vault_resolve` are unchanged and still run before any consumer. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2029-rune-markdown-tui-notes-window.md b/docs/adr/ADR-2029-rune-markdown-tui-notes-window.md index cb2e5f7fa..106098318 100644 --- a/docs/adr/ADR-2029-rune-markdown-tui-notes-window.md +++ b/docs/adr/ADR-2029-rune-markdown-tui-notes-window.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [flake.nix, lib/rune.nix, config/tmux-autostart.sh, config/tmux.conf, agentbox.toml, setup/agentbox.default.toml, schema/agentbox.toml.schema.json] owner: jjohare review_trigger: a Rune release that changes its CLI (`-w`), its keyboard-protocol requirement, or its licence; or the AoE plane absorbing note editing @@ -198,3 +198,37 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port; `schema/agentbox.toml.schema.json` changes only `payments.properties.sidestr` (`producer_url` optional, `mirror_url` added). Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. **D4 amended in effect by custody W0.** The entrypoint no longer adds `/home/devuser/workspace/.cargo/bin` to `PATH` globally: root's boot `PATH` is store-only (a planted binary there ran as root), and devuser shells get the directory *appended* (runtime-env and fish snippets). Window 9 is unaffected for two reasons. With `[vault].tui = "rune"`, rune is baked (`lib/rune.nix`). And `config/tmux-autostart.sh` re-adds the cargo bin via `new-window -e` for the interim fallback. The decision holds, but D4's sentence "the entrypoint adds … to `PATH`" no longer describes root. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. `setup/agentbox.default.toml` received the same comment correction on its `b41654017f…` relay entry and nothing else. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `agentbox.toml` changed only in the trailing comments of two allowlist entries (`d3ff8e9a8`, the Q15 correction: `b4165401` is the operator's decision signer). `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). `setup/agentbox.default.toml` changed only in the same allowlist comment (`d3ff8e9a8`). +The Rune window and the vault root it opens are unchanged. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. The Rune window and its vault root are untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2030-permissive-licensing-for-publishable-service-crates.md b/docs/adr/ADR-2030-permissive-licensing-for-publishable-service-crates.md index 000b54bf1..2575f764a 100644 --- a/docs/adr/ADR-2030-permissive-licensing-for-publishable-service-crates.md +++ b/docs/adr/ADR-2030-permissive-licensing-for-publishable-service-crates.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: fc56e7a97cb437502c09d8f512cbb547efb6546c +verified_commit: 3b54129631067277f6363309b01cce485faa027a verified_paths: [services/LICENSING-NOTICE.md, docs/developer/licensing.md, scripts/ci/check-crate-licensing.sh, services/*/Cargo.toml] owner: jjohare review_trigger: any new crate under services/, any services crate gaining an AGPL dependency, or first publication of a services crate to crates.io @@ -335,3 +335,19 @@ Verified at `7f1cdaaad68fac7681cec1e510765f979037aee1` (`verified_commit`): gate ## Re-verification — 2026-09-26 (`4f9450ac86477bc3832933953454ea577bd3d531`) Tripped by `services/dream-engine/Cargo.toml` gaining `base64 = "0.22"` as a **dev-dependency** (test-only NIP-44 payload shape check in `src/zone_crypto.rs`; already in the lockfile transitively, MIT OR Apache-2.0). The crate's declared licence is unchanged (`AGPL-3.0-only`, `nostr-bbs-core` linked); `sh scripts/ci/check-crate-licensing.sh` → `OK … 10 services/ package directories carry the texts they declare`. Decision unaffected. + +## Re-verification — 2026-10-03 (`26fc543d2a98f1e3d192408ea4cd13d10cd59ddf`) + +Tripped by `services/nostr-pod-bridge/Cargo.toml` on the custody W3 branch, which gains the identity port. Its dependency changes are `hmac = "0.12"`, which arrived with W8's mirror-key commit (cherry-picked unchanged and re-verified on W8's own branch), and three normal dependencies, `url = "2"` (2.5.8), `base64 = "0.22"` (0.22.1) and `zeroize = "1"` (1.9.0). All three are `MIT OR Apache-2.0` and were already in the lock transitively, so the lock gains three edges and no package. The crate's declared licence is unchanged (`AGPL-3.0-only`). `sh scripts/ci/check-crate-licensing.sh` → `OK … 9 services/ package directories carry the texts they declare`. Decision and status unchanged. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by `services/nostr-pod-bridge/Cargo.toml` gaining `hmac = "0.12"` as a normal dependency, for the G-5 mirror-child known-answer test (`src/mirror_key.rs`). `hmac` 0.12.1 is `MIT OR Apache-2.0` and was already in the lock transitively through `nostr-bbs-core`, so the lock gains one edge and no package. The crate's declared licence is unchanged (`AGPL-3.0-only`). `sh scripts/ci/check-crate-licensing.sh` reports `OK … 9 services/ package directories`, the same count as `origin/main` (`0919dc39a`). The drop from the ten above predates this change. Decision and status unchanged. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `d3ff8e9a8` the governed paths changed as follows. `services/nostr-pod-bridge/Cargo.toml` gains `url`, `base64` and `zeroize` for the identity port (`a44ea413f`, W3) and `hmac` as a normal dependency for the G-5 mirror-child known-answer test (`86fc90e3c`). All four are permissively licensed and already in the lock graph; none is a new cryptographic primitive. +Every service crate's licence stays MIT OR Apache-2.0, and the new dependencies are permissive. The decision holds. Re-verified by `git log d3ff8e9a8..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. diff --git a/docs/adr/ADR-2031-consultant-model-manifest-projection.md b/docs/adr/ADR-2031-consultant-model-manifest-projection.md index dfeba0794..2d5ba40ca 100644 --- a/docs/adr/ADR-2031-consultant-model-manifest-projection.md +++ b/docs/adr/ADR-2031-consultant-model-manifest-projection.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: a48ea407a24185f7a4f654a35e66805778acbec8 +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [config/entrypoint-unified.sh, services/agentbox-manifest/src/tui_write.rs, mcp/consultants/antigravity/server.js, skills/mcp.json] owner: jjohare review_trigger: any change to a consultant's default model, a Gemini model retirement, the 2027-01-01 Gemini tariff step, or a wizard that starts exposing the consultant model field @@ -146,3 +146,29 @@ One governed path moved, `config/entrypoint-unified.sh`, in a COMMENT-ONLY hunk: Tripped by ADR-2078 (pods signer signs as the sovereign identity). `config/entrypoint-unified.sh` changes only in Phase 3: an `AGENTBOX_IDENTITY_ROOT` default export before `nostr-pod-bridge bootstrap`, and a chown to devuser plus chmod 0600 of the bootstrap identity file after it (ADR-2078). It touches no section, key or phase this record governs, and the decision holds unchanged. Re-verified by `git diff caab741c6..a48ea407a -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). The consultant-model projection block is untouched. `tests/consultant_model.rs` passes (cargo, 161 tests). The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. +Consultant model projection is untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The consultant-model projection block is untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2033-deepsec-security-gate.md b/docs/adr/ADR-2033-deepsec-security-gate.md index 59a9e8da4..1c8c53b32 100644 --- a/docs/adr/ADR-2033-deepsec-security-gate.md +++ b/docs/adr/ADR-2033-deepsec-security-gate.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: staged supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [flake.nix, agentbox.toml, schema/agentbox.toml.schema.json, scripts/agentbox-config-validate.js, management-api/lib/system-manifest.js, skills/build-with-quality/scripts, skills/build-with-quality/references/deepsec-security-gate.md, .github/workflows/deepsec.yml] owner: jjohare review_trigger: a deepsec major version, a change to its CLI exit-code contract or model-route schema, any new model route, or the first paid full-repo run @@ -239,3 +239,37 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port; `schema/agentbox.toml.schema.json` changes only `payments.properties.sidestr` (`producer_url` optional, `mirror_url` added). Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. The schema gains `security.role_isolation` beside `security.deepsec`, and the catalogue gains a sibling `role-isolation` entry. The deepsec package, policy and catalogue row are unchanged. `check-manifest-catalogue` passes. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `agentbox.toml` changed only in the trailing comments of two allowlist entries (`d3ff8e9a8`, the Q15 correction: `b4165401` is the operator's decision signer). `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). `management-api/lib/system-manifest.js` changes two summary strings only (the producer runs the baked upstream; the roles config's name). +deepsec stays baked and manifest-gated, and its credential handling is unchanged. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. The deepsec gate and its credential policy are untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2066-pod-signing-key-path-is-reachable.md b/docs/adr/ADR-2066-pod-signing-key-path-is-reachable.md index 2ddb4633e..e85375aae 100644 --- a/docs/adr/ADR-2066-pod-signing-key-path-is-reachable.md +++ b/docs/adr/ADR-2066-pod-signing-key-path-is-reachable.md @@ -89,3 +89,20 @@ empty. 0 failed, including `tests/sovereign/pod-signer.test.js` and `tests/sovereign/elevation-publisher.test.js`, which exercise `loadSigner` through the `profilesRoot` override and are unaffected by the default change. + +## Note — 2026-10-03: the devuser key path under `[security].role_isolation` (ADR-2122) + +With the flag on, the devuser-readable copies of the sovereign key that a pod signer in +management-api could reach are removed from devuser's view: + +- the boot no longer writes `/run/secrets/nostr.key` (devuser `0400`); +- `AGENTBOX_PRIVKEY_HEX`, `AGENTBOX_NSEC` and `AGENTBOX_BRIDGE_SK` are written to + `/run/secrets/ab-identity/` (`0400`, uid 960) and then unset from PID 1's environment; +- `/run/secrets` itself is root-owned `0711` on its own tmpfs. + +The fallback `$WORKSPACE/profiles//nostr.key.enc` path that this record repaired is +**not** removed by W1. It goes when the pods signer moves to the identity port (custody W3b; +ADR-2078 amended). Once that lands, this record is superseded by ADR-2122 for "where the pod +signer gets its key". `superseded_by` is not set while ADR-2122 is `proposed`. With the flag off +nothing here changes. Status fields are unchanged: this note records a dependency and is not a +re-verification of this record's paths. diff --git a/docs/adr/ADR-2078-provision-pods-signer-from-sovereign-identity.md b/docs/adr/ADR-2078-provision-pods-signer-from-sovereign-identity.md index 75055f6e2..51728fdb5 100644 --- a/docs/adr/ADR-2078-provision-pods-signer-from-sovereign-identity.md +++ b/docs/adr/ADR-2078-provision-pods-signer-from-sovereign-identity.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: a48ea407a24185f7a4f654a35e66805778acbec8 +verified_commit: 3b54129631067277f6363309b01cce485faa027a verified_paths: [management-api/lib/pod-signer.js, management-api/lib/agent-identity.js, management-api/adapters/index.js, management-api/adapters/pods/_solid-http-base.js, tests/sovereign/pod-sovereign-signer.node-test.js] owner: jjohare review_trigger: ADR-2064 flipping sign_requests back to true, a change to nostr-pod-bridge bootstrap key layout, or a second stack needing its own pod identity @@ -145,3 +145,20 @@ entrypoint change runs at boot. `activation_status` moves to `live` once case 4 rebuilt image: `./agentbox.sh health` and `/ready` green with the flag on, and a pods write carrying a header that verifies as the `agentbox-core.json` pubkey. Until then, and after it if the pod is not running, a pods slot failing closed with `SigningUnavailable` is the expected, accepted state (SC3). + +## Re-verification — 2026-10-03 (`26fc543d2a98f1e3d192408ea4cd13d10cd59ddf`) + +Tripped by `management-api/lib/pod-signer.js` gaining a third, flag-gated signing source: the identity port (custody X-1 step 1, W3; ADR-2122). Under `[security].role_isolation`, or with `[integrations.solid_pod_rs].sign_source = "identity-port"`, the header comes from `nostr-pod-bridge sign-request nip98`. That source is then the only one, so no identity file or stack key is read. The port signs with the same sovereign key this record names; what changes is custody of the key, not which identity signs. With the flag off and `sign_source` unset, nothing new runs and this record's decision holds unchanged. `pod-sovereign-signer.node-test.js` still passes 13/13. ADR-2064's fail-closed behaviour carries over: a refused or absent port throws, and the adapter raises `SigningUnavailable` with zero requests sent (`tests/sovereign/identity-port.node-test.js`, 10/10 against the real binary and `NostrBridge.verifyNip98`). Design §5 calls for an amendment to this record ("under the flag the signer source is the port"). That amendment is for ADR-2122 to make once it lands. It is not made here. Status unchanged. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `management-api/lib/agent-identity.js`: `loadSovereignSigner` and `sovereignIdentityPath` are unchanged; the file re-exports the ROLE-secret loader and `loadOrMint` reads its override through it. Under the flag `identity.env` no longer exports `AGENTBOX_NSEC`; the pods signer never read it (it reads the identity file), so cases 1–4 are unaffected. `tests/sovereign/pod-sovereign-signer.node-test.js` passes 13/13. The decision holds. +Re-verified by `git diff a48ea407a..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `management-api/lib/pod-signer.js` gains the identity-port source (`9e87ae402`, W3). It is selected only by `[security].role_isolation` or `sign_source = "identity-port"`, and is then the only source; a refusal or an absent port becomes `SigningUnavailable` before a byte is sent. +Case 1 (the identity file) is unchanged with the flag off. Under the flag the port signs with the same core key, and ADR-2122 records it. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. diff --git a/docs/adr/ADR-2082-orchestration-proxy-behind-governed-memory-server.md b/docs/adr/ADR-2082-orchestration-proxy-behind-governed-memory-server.md index 57bdee6a3..cd61498bd 100644 --- a/docs/adr/ADR-2082-orchestration-proxy-behind-governed-memory-server.md +++ b/docs/adr/ADR-2082-orchestration-proxy-behind-governed-memory-server.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: a48ea407a24185f7a4f654a35e66805778acbec8 +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [mcp/servers/lib/orchestration-proxy.js, mcp/servers/ruvector-mcp.cjs, mcp/servers/lib/ruvector-gates.js, config/entrypoint-unified.sh] owner: jjohare review_trigger: next image rebuild (activation), a ruflo major bump that renames the swarm/agent/task/coordination tools, or any proposal to forward a memory_* tool @@ -128,3 +128,29 @@ One governed path moved, `config/entrypoint-unified.sh`, in a COMMENT-ONLY hunk: Tripped by ADR-2078 (pods signer signs as the sovereign identity). `config/entrypoint-unified.sh` changes only in Phase 3: an `AGENTBOX_IDENTITY_ROOT` default export before `nostr-pod-bridge bootstrap`, and a chown to devuser plus chmod 0600 of the bootstrap identity file after it (ADR-2078). It touches no section, key or phase this record governs, and the decision holds unchanged. Re-verified by `git diff caab741c6..a48ea407a -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). The orchestration-proxy registration is untouched. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. +The orchestration proxy and its memory fence are untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The orchestration-proxy registration is untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2088-grill-before-acting-on-forum-suggestions.md b/docs/adr/ADR-2088-grill-before-acting-on-forum-suggestions.md index f8539b569..30001b245 100644 --- a/docs/adr/ADR-2088-grill-before-acting-on-forum-suggestions.md +++ b/docs/adr/ADR-2088-grill-before-acting-on-forum-suggestions.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: e434a7a596a3a0518c51b7da107d6e0831891910 +verified_commit: 275e12356319a9630846656580d497d53de3d38c verified_paths: [management-api/lib/junkiejarvis-clarify.js, management-api/lib/junkiejarvis-agent.js, management-api/server.js, scripts/dream-forum-suggestions.mjs, scripts/run-junkiejarvis.cjs, tests/sovereign/junkiejarvis-clarify.test.js, tests/sovereign/junkiejarvis-dm-send.test.js, tests/sovereign/dream-forum-suggestions-jj-gate.test.js] owner: jjohare review_trigger: any change to the clarity signals, MIN_SPECIFICITY, the 7-day expiry, or the forum-suggestions ingest path @@ -159,3 +159,8 @@ Re-verified by `git diff 66425f9bd..6db0ffc8d -- `; no re-implem ## Re-verification — 2026-10-02 (`e434a7a596a3a0518c51b7da107d6e0831891910`) Tripped by the sidechain health and witness change. `management-api/server.js` changes only in `GET /ready`. It now probes each enabled sidestr chain's tip age and lists a stale one under a non-blocking `degraded` array (ADR-2103 interim receipt amendment). The JunkieJarvis wiring and the clarify path are unchanged. The three governed JunkieJarvis suites pass (70/70 within the 99-test jest run). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `management-api/lib/junkiejarvis-agent.js` (`readPrivHex`) and `scripts/dream-forum-suggestions.mjs` read the JunkieJarvis key through the ROLE-secret loader. Flag off: the same read (the jj-gate suite passes 3/3, the agent and dm-send suites unchanged). **Consequence under the flag with W1:** the key file is `ab-identity` 0400, so the devuser agent finds no key and does not start (fail closed) until it signs through the identity port (`forum_event`, W3b). The grill-before-acting behaviour is untouched. The decision holds. +Re-verified by `git diff e434a7a59..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. diff --git a/docs/adr/ADR-2090-skill-routing-prompt-egress.md b/docs/adr/ADR-2090-skill-routing-prompt-egress.md index 1b6c628a0..3f2bc2589 100644 --- a/docs/adr/ADR-2090-skill-routing-prompt-egress.md +++ b/docs/adr/ADR-2090-skill-routing-prompt-egress.md @@ -76,3 +76,15 @@ decision ADR-2091 relies on — and its `implementation_status` stays `none` bec described work of its own. The two carried-over consequences are now tested rather than stated: fail-open on 429/529/timeout and the no-retry rule are contract cases in `tests/config/skill-route.test.js`. The per-project bypass remains the open debt. + +## Cross-reference — 2026-10-03 (prompt egress register, N-7) + +This record is the `skill-router` row's accepted-egress record in the +[prompt egress register](../SECURITY-profiles.md#prompt-egress-register--2026-10-03) +(`config/egress-policy.json` `.register`), which catalogues every route by which a prompt, +code, a secret-adjacent string or a key leaves the box, beside the routes this record does +**not** widen to. Re-verified at `0919dc39a`: the router still sends the user's turn, clamped +head+tail (`config/hooks/lib/skill-route.cjs:408-414`), to `api.typesafe.ai` +(`config/hooks/lib/skill-route.cjs:33`); `[skills.routing].router` (`agentbox.toml:962`) is +still the switch; and no per-project gate or bypass exists yet, so the debt in Consequences +stands. Decision and status axes unchanged. diff --git a/docs/adr/ADR-2091-live-skill-router.md b/docs/adr/ADR-2091-live-skill-router.md index 804c851ec..dd0cd6fb7 100644 --- a/docs/adr/ADR-2091-live-skill-router.md +++ b/docs/adr/ADR-2091-live-skill-router.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: a48ea407a24185f7a4f654a35e66805778acbec8 +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [config/hooks/lib/skill-route.cjs, config/hooks/skill-route.cjs, skills/skill-router/scripts/route.mjs, config/entrypoint-unified.sh, tests/config/skill-route.test.js] owner: jjohare review_trigger: the first project that needs a per-project routing bypass (ADR-2090), a Jev model change, or a measured runtime-path accuracy below 85% on the 40-item set @@ -167,3 +167,29 @@ Gates at this commit: `jest tests/config/skill-route.test.js tests/config/routin Tripped by ADR-2078 (pods signer signs as the sovereign identity). `config/entrypoint-unified.sh` changes only in Phase 3: an `AGENTBOX_IDENTITY_ROOT` default export before `nostr-pod-bridge bootstrap`, and a chown to devuser plus chmod 0600 of the bootstrap identity file after it (ADR-2078). It touches no section, key or phase this record governs, and the decision holds unchanged. Re-verified by `git diff be358df7b..a48ea407a -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). The skill-router projection is untouched. It runs in Stage B, which is now one-shot per container start, so a `supervisorctl start bootstrap` replay no longer re-projects; a container restart does. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. +The skill router hook and its fail-open path are untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The skill-router projection is untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2092-govern-the-agent-and-command-registries.md b/docs/adr/ADR-2092-govern-the-agent-and-command-registries.md index 8ea18acd0..dc57d1012 100644 --- a/docs/adr/ADR-2092-govern-the-agent-and-command-registries.md +++ b/docs/adr/ADR-2092-govern-the-agent-and-command-registries.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: f7465412de3d0d7a25fc1b6b2c8a72775490616d +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [agents/registered-agents.txt, scripts/reconcile-agents.sh, scripts/reconcile-commands.sh, scripts/project-skill-roots.mjs, config/registered-commands.txt, config/entrypoint-unified.sh, flake.nix, tests/config/agent-reconcile.test.sh] owner: jjohare review_trigger: a new subagent worth always-loading, or evidence the router surfaces baked-but-unregistered skills too slowly @@ -197,3 +197,33 @@ Tripped by the `sidestr:dreamlab-txbt4` seal. `flake.nix` adds `sidechainChains` - the manifest validator is valid; - `check-manifest-catalogue` passes; - `tests/config/sidechain-genesis.test.sh` passes 7/7 and `sidechain-producer-gates.test.sh` 7/7. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). The agent and command reconcilers are untouched. They run in Stage B, now one-shot per start, and a restart re-runs them. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +Agent and command registry governance is untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off; `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The agent and command reconcilers are untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2093-jev-verbatim-compaction.md b/docs/adr/ADR-2093-jev-verbatim-compaction.md index c23753227..fff5630a2 100644 --- a/docs/adr/ADR-2093-jev-verbatim-compaction.md +++ b/docs/adr/ADR-2093-jev-verbatim-compaction.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: a48ea407a24185f7a4f654a35e66805778acbec8 +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [lib/factrail.nix, config/entrypoint-unified.sh, lib/claude-code-binary.nix] owner: jjohare review_trigger: the first measured residency bill that exceeds the summary path's re-read savings, a Claude Code function-hook API change, a request to fence a class other than email, or a change to ADR-2121 (its implementation) @@ -307,3 +307,29 @@ Other entrypoint changes (hook timeouts in seconds, the hook registry, permissio Tripped by ADR-2078 (pods signer signs as the sovereign identity). `config/entrypoint-unified.sh` changes only in Phase 3: an `AGENTBOX_IDENTITY_ROOT` default export before `nostr-pod-bridge bootstrap`, and a chown to devuser plus chmod 0600 of the bootstrap identity file after it (ADR-2078). It touches no section, key or phase this record governs, and the decision holds unchanged. Re-verified by `git diff caab741c6..a48ea407a -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). The Jev compaction projection is untouched. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. +Jev compaction's switch and email fence are untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The Jev compaction projection is untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2103-parent-chain-and-header-profile-are-configuration-behind-the-p21-gate.md b/docs/adr/ADR-2103-parent-chain-and-header-profile-are-configuration-behind-the-p21-gate.md index acfc498e7..8819ab817 100644 --- a/docs/adr/ADR-2103-parent-chain-and-header-profile-are-configuration-behind-the-p21-gate.md +++ b/docs/adr/ADR-2103-parent-chain-and-header-profile-are-configuration-behind-the-p21-gate.md @@ -7,8 +7,8 @@ implementation_status: partial activation_status: inactive supersedes: [] superseded_by: [] -verified_commit: 05d886bba7cc7c73c4de8ae47680785c4f81c079 -verified_paths: [config/sidechain/dreamlab/chain.json, config/sidechain/dreamlab-txbt4/chain.json, config/sidechain/README.md, config/sidechain/run-producer.sh, tests/config/sidechain-genesis.test.sh, tests/config/sidechain-producer-gates.test.sh, management-api/lib/sidechain-health.js, scripts/activation/sidechain-demo-witness.sh, scripts/activation/sidechain-witness.cjs, scripts/activation/sidechain-witness-replay/src/main.rs] +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf +verified_paths: [config/sidechain/dreamlab/chain.json, config/sidechain/dreamlab-txbt4/chain.json, config/sidechain/README.md, config/sidechain/run-producer.sh, config/sidechain/upstream-pins, lib/sidestr-upstream.nix, tests/config/sidechain-genesis.test.sh, tests/config/sidechain-producer-gates.test.sh, tests/config/sidechain-producer-baked.test.sh, management-api/lib/sidechain-health.js, scripts/activation/sidechain-demo-witness.sh, scripts/activation/sidechain-witness.cjs, scripts/activation/sidechain-witness-replay/src/main.rs] owner: jjohare review_trigger: sidestr/spec PR #4 and sidestr/explorer PR #2 merging or being declined; a new alias in the SPEC 3.2 parent table; any proposal to sign a chain document whose parent is a mainnet variant; a change to the Knots BLAKE2b fork's header format or activation; a BLAKE2b testnet4 node reachable from the container; upstream implementing assets between chains (assets-and-pools section 4) repo: agentbox @@ -568,3 +568,60 @@ Evidence, per the upstream-pins rule (a pin moves only after a block is produced both pins. Decision and status unchanged: the parent chain and header profile remain configuration behind the P21 gate; the pin records which engine the producer runs. + +## Re-verification — 2026-10-03 (chain-event announce) + +`b6c44c9a5` added a four-line change to `config/sidechain/run-producer.sh`: when a +`chain-event.json` sits beside the chain document, the producer is started with +`--chain-event` so its tips carry the chain hash (SPEC 0.0.5). The producer's parent check, +the seal, the checkpoint position (open, SC5) and this record's decision are unchanged. +Re-verified at `0919dc39afb7d4ab22bfc14a431f0d55ef1b81b3`. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by `b6c44c9a5` (SPEC 0.0.5, on main before the custody branches), which surfaced while re-verifying for custody W1. `config/sidechain/run-producer.sh` gains one optional argument: `--chain-event "$STATE/chain-event.json"` is passed when that file exists. The parent check (D3), the BLAKE2b fork-hash check (D3a), the P21 mainnet gate and the "no implied anchor" message are untouched. `tests/config/sidechain-producer-gates.test.sh` passes 7/7 and `sidechain-genesis.test.sh` 7/7. Custody W1 does not edit the runner. Under `[security].role_isolation` it only points `SIDESTR_KEY` and `SIDESTR_PARENT_COOKIE` at `/run/secrets/ab-sidestr-/` (ADR-2122). The decision holds. +Re-verified by `git diff 05d886bba..055c06ff6 -- config/sidechain/run-producer.sh`. No re-implementation was needed. + +## Re-verification — 2026-10-03 (`e103f81a769c7a762786f6bf7fcb6a6e00ff544e`, custody W5: the producer runs baked code) + +`run-producer.sh` no longer runs the workspace checkouts by default. `lib/sidestr-upstream.nix` +bakes sidestr/spec, bitcoin-desktop/schema and bitcoin-blake/blaketestnode at the commits in +`config/sidechain/upstream-pins` into one read-only `/nix/store` tree, linked at +`/opt/agentbox/sidestr/upstream` whenever `[sidechain].enabled`. Each directory records its +commit in `.pin-commit`, and Nix evaluation fails when the file's revs and `upstream-pins` +disagree. The runner refuses a bake whose `.pin-commit` differs (a stale bake), a bake with any +file writable by the producer's user, and an image with no bake. A workspace checkout runs only +under `SIDESTR_ALLOW_UNPINNED=1`, and every such start logs `SIDESTR-UNPINNED`, naming the +directories that are off their pin or carry uncommitted edits. The old `rev-parse HEAD` +comparison let an uncommitted edit through; that path now exists only behind the override. + +This is the default **with `[security].role_isolation` off as well as on**. The flag ships off +and only changes which uid runs the producer and where its key is read from (ADR-2122). Running +pinned code is a property of the runner, not of the custody flag, and a pinned +default is the honest one in either mode. + +The bake carries no `node_modules`. siding's producer path imports only `node:` builtins. +`@ethereumjs/*` loads lazily, and only for a chain whose document names the `evm` rule. Neither +`dreamlab` nor `dreamlab-txbt4` names it, and the runner refuses such a chain on this bake. + +Unchanged: the parent check (D3), the BLAKE2b fork-hash check (D3a), the checkpoint and +"no implied anchor" gates, the P21 mainnet gate and both sealed documents. Evidence: +`tests/config/sidechain-producer-baked.test.sh` 8/8 (red 0/8 before the runner change), +`sidechain-producer-gates.test.sh` 7/7, `sidechain-genesis.test.sh` 7/7, shellcheck clean. A +bake assembled from the three real commit tarballs passes every new gate and stops at D3a. The +fetch hashes were computed with the NAR method, which reproduces `lib/sidestr-agent.nix`'s +pin exactly; the derivation itself is unbuilt until the owner's host rebuild. Until that +rebuild the live producer stays on its tmux bridge under `SIDESTR_ALLOW_UNPINNED=1`, which the +new runner still serves, now with the marker. Decision and status axes unchanged. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `e103f81a7` the governed paths changed as follows. `tests/config/sidechain-producer-baked.test.sh` was touched by W5's own re-verification commit (`a75491dba`) after the stamp it carries. It passes 8/8 here. +The parent check, the seal, the P21 gate and the checkpoint position are unchanged; the producer gates pass 7/7 and genesis 7/7. The decision holds. Re-verified by `git log e103f81a7..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W4) + +Tripped by `f93586b9e`. `config/sidechain/run-producer.sh` changes only in where the block files live. Under `[security].role_isolation` the producer runs as its role with HOME on the `/run/secrets` tmpfs, so `SIDESTR_STATE` now defaults to `/var/lib/agentbox/events/sidestr/` on the agentbox-events volume (umask 027, so the devuser mirror reads it through the group), seeded once from the workspace state, which stays. With the flag off the default is unchanged, and the runner refuses to start (`CUSTODY-STATE-AHEAD`) when the custody `blocks.dat` has outgrown the workspace copy, because starting from the workspace would fork the chain. The parent check, the seal, the P21 gate, the checkpoint position and the baked upstream are unchanged; the producer gates pass 7/7 and the baked-upstream suite 8/8. The decision holds. Re-verified by `git log 3b5412963..f93586b9e -- `. diff --git a/docs/adr/ADR-2105-agentbox-kind-bands-and-the-colloquy-move.md b/docs/adr/ADR-2105-agentbox-kind-bands-and-the-colloquy-move.md index 393b34e4f..9d5a08854 100644 --- a/docs/adr/ADR-2105-agentbox-kind-bands-and-the-colloquy-move.md +++ b/docs/adr/ADR-2105-agentbox-kind-bands-and-the-colloquy-move.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: staged supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [crates/colloquy/colloquy-nostr/src/kinds.rs, docs/PROTOCOL-registry.md, services/nostr-pod-bridge/src/colloquy_publish.rs, agentbox.toml] owner: jjohare review_trigger: the next agentbox Nostr kind allocation, or any change to the band table in docs/PROTOCOL-registry.md @@ -191,3 +191,24 @@ Tripped by the sidechain health and witness change. `agentbox.toml` changed only Tripped by ADR-2097 (the rail keyed by chain). `agentbox.toml` changes only inside `[payments.sidestr]` (ADR-2097): its comment block, `chain_id` now `sidestr:dreamlab-txbt4`, and `producer_url` dropped in favour of the chain's derived port. Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff e434a7a59..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `agentbox.toml` gains only `[security].role_isolation = false` with its comment (ADR-2122); no other key moved. No kind band is touched, and the decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`d3ff8e9a876e6b026b543f8824487e002e04cfa0`) + +Tripped by the G-5 Q15 correction (`custody/w8-key-split`). `agentbox.toml` changed only in the trailing comments of two allowlist entries: `b41654017f…2f7a` is relabelled as the operator's NIP-07 31403 decision signer (it is `[sovereign_mesh.operator].pubkey_hex`), not visionclaw-server, and the `11ed6422…663c` entry in `[interaction_plane.proxy]` notes that its Podkey-vault copy is to be replaced by K_browser. No key, value, table or list member moved. `node scripts/agentbox-config-validate.js agentbox.toml` is valid with the same 5 advisory warnings as `origin/main` (`0919dc39a`). Decision and status unchanged. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `d3ff8e9a8` the governed paths changed as follows. `agentbox.toml` gains `[security].role_isolation = false` with its comment (`49961f88c`, ADR-2122); no other key changes. +No kind band changes. The decision holds. Re-verified by `git log d3ff8e9a8..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `agentbox.toml` changes only in the comment above `[security].role_isolation = false`: it no longer says the identity port and the custody migration are absent, and names what is built (W3, W2b, W4) and what is owed (W3b). No key or value moves. No port band moves. The decision holds. Re-verified by `git log 3b5412963..f93586b9e -- `. diff --git a/docs/adr/ADR-2110-routing-teacher-labels-from-main-model-use.md b/docs/adr/ADR-2110-routing-teacher-labels-from-main-model-use.md index 8a2ebb191..f7acb3e11 100644 --- a/docs/adr/ADR-2110-routing-teacher-labels-from-main-model-use.md +++ b/docs/adr/ADR-2110-routing-teacher-labels-from-main-model-use.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: c7b5d5f5535c9203f21b24ed710705a3fe16dcf2 +verified_commit: 275e12356319a9630846656580d497d53de3d38c verified_paths: [config/hooks/routing-label-recorder.cjs, config/hooks/lib/routing-labels.cjs, tests/config/routing-labels.test.js, scripts/experiments/exp-b8-label-log.cjs, tests/config/exp-b8-label-log.test.js] owner: jjohare review_trigger: the EXP-B8 stopping rule firing (510 analysable rows or 2026-10-20) and its verdict PR; the first 30 days of recorded labels; a learned router measured against the frozen corpus; any change to what the label row stores @@ -196,3 +196,8 @@ since we might forget … turn it off once the data is statistically significant - **Next:** The EXP-B8 tick fires the stop by 2026-10-20 at the latest and opens the verdict PR. KEEP → take open question 1 (embedding retention) to the owner. WITHDRAW or INCONCLUSIVE → this record becomes rejected. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `scripts/experiments/exp-b8-label-log.cjs` reads the JunkieJarvis key through the ROLE-secret loader; with the flag off its pre-W2 read (env, then the repo `.env` via `zone-keys.readSetting`) is kept as is. Label recording is untouched (`tests/config/exp-b8-label-log.test.js` 34/34). The decision holds. +Re-verified by `git diff c7b5d5f55..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. diff --git a/docs/adr/ADR-2115-dream-decisions-surface-on-the-forum-governance-panel.md b/docs/adr/ADR-2115-dream-decisions-surface-on-the-forum-governance-panel.md index a52a7e95e..ff21f35ec 100644 --- a/docs/adr/ADR-2115-dream-decisions-surface-on-the-forum-governance-panel.md +++ b/docs/adr/ADR-2115-dream-decisions-surface-on-the-forum-governance-panel.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: ddfb6d05608da573f071029476f8e2ebbee37bf1 +verified_commit: 275e12356319a9630846656580d497d53de3d38c verified_paths: [services/dream-engine/src/governance.rs, services/dream-engine/src/digest.rs, services/dream-engine/src/relay.rs, services/dream-engine/src/inbox.rs, services/dream-engine/src/engine.rs, config/hooks/dream-inbox-surface.cjs] owner: jjohare review_trigger: JunkieJarvis registered in the relay agent_registry and the first night that publishes cases (activation_status → live), or any change to the forum's 31402/31403 wire format @@ -76,3 +76,8 @@ Tripped by `engine.rs` gaining a connected-node health gate (`dispatch::annexe_h ## Re-verification — 2026-10-02 (`ddfb6d05608da573f071029476f8e2ebbee37bf1`) Tripped by `68270e953`, which extends this decision without changing it. Once an inbox item is no longer open, the engine withdraws its kind-31402 with a NIP-09 kind-5 signed by the publishing key. The kind-5 carries `e` = request id, `a` = `31402::` (withheld when a re-opened item shares the id) and `k` = 31402. This happens in `ingest` for items it just resolved, in the nightly `publish` sweep, and via `dream-engine governance withdraw [--dry-run]`. `inbox.rs` records `withdrawn_event_id` (serde default, so old inbox files load), and a withdrawal is never re-sent. It is fail-open, like all forum I/O here. "One kind-31402 case per open inbox item" is now true of what the relay serves, not only of what is published. `cargo test governance::` 15 passed; full crate 248 passed. Activation note: the supervised `dream-engine` in the running image predates `68270e953` (no withdrawal strings in the binary), so the nightly sweep needs the rebuild; check A4 of `scripts/activation/adr-2087-check.sh` asserts it. Still true, extended. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `services/dream-engine/src/relay.rs` `load_signing_key` gains the ROLE-secret contract: `_FILE` first; the env var and then the repo `.env` only with the flag off; under the flag a bare variable is a logged leak and `.env` is not read. Signing and verification are unchanged (dream-engine 253/253). **Consequence under the flag with W1:** the devuser engine cannot read the `ab-identity` key file, so panel and digest posts stop (fail-open: "the forum is unreachable tonight") until `forum_event` on the identity port (W3b). The decision holds. +Re-verified by `git diff ddfb6d056..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. diff --git a/docs/adr/ADR-2118-own-the-instruction-tiers-and-claude-home-in-the-repo.md b/docs/adr/ADR-2118-own-the-instruction-tiers-and-claude-home-in-the-repo.md index 4e19adeed..105ddd498 100644 --- a/docs/adr/ADR-2118-own-the-instruction-tiers-and-claude-home-in-the-repo.md +++ b/docs/adr/ADR-2118-own-the-instruction-tiers-and-claude-home-in-the-repo.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: f7465412de3d0d7a25fc1b6b2c8a72775490616d +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [config/instructions, services/agentbox-manifest/src/instructions.rs, services/agentbox-manifest/src/cred_sync.rs, config/entrypoint-unified.sh, agentbox.sh, flake.nix, docker-compose.yml, docker-compose.override.yml, docker-compose.hp.yml, tests/config/claude-home-migration.test.sh, tests/config/compose-persistence.test.cjs] owner: jjohare review_trigger: the connected node runs migrate-claude-home; or Claude Code starts reading AGENTS.md natively (drop the @AGENTS.md wrappers and the embed); or a Claude Code release changes where credentials live @@ -86,3 +86,45 @@ Tripped by the `sidestr:dreamlab-txbt4` seal. `flake.nix` adds `sidechainChains` - the manifest validator is valid; - `check-manifest-catalogue` passes; - `tests/config/sidechain-genesis.test.sh` passes 7/7 and `sidechain-producer-gates.test.sh` 7/7. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). `docker-compose.yml` gains the `/run/secrets` tmpfs line, and `docker-compose.override.yml` changed only in W0's Docker socket comments. The instruction mounts, `instructions-project` and `cred-sync` are untouched. One risk is carried from the custody design (R4): with devuser out of group root, anything that relied on group-0 read bits now fails. Whether any mounted instruction file depends on group 0 is unverified until the rebuild. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `agentbox.sh` gains W9's `browsercontainer_fetch_podkey` before sidecar builds and a `browsercontainer podkey` subcommand (`7c10e502b`); nothing outside the browsercontainer verbs changes. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +The instruction tiers and the Claude home are untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`31a54c2f3b03663c6ed2f90196d81dbfd113af57`, custody docs pass) + +Tripped by `31a54c2f3`, the custody docs pass. The only governed path that changed is the +tracked workspace layer `config/instructions/workspace.md`. Its "Host access & Docker builds" +section gains one paragraph of container facts for `[security].role_isolation` (ADR-2122, +staged): under the flag `docker exec` from inside is refused by the GET-only +`/run/docker-ro.sock`, role secrets sit in the root-owned `/run/secrets//`, and signing goes +through the identity port. This is content in a tier, not a change to how the tiers are +projected. It is tool-neutral and has no estate specifics, so it belongs in the tracked layer. +`tests/config/instructions-layers.test.sh` passes 9/9. The decision holds. Re-verified by +`git log 32cedf992..31a54c2f3 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off; `docker-compose.hp.yml` (`248d36f15`) drops `AGENTBOX_NSEC` and `AGENTBOX_PRIVKEY_HEX` from `environment:`; `env_file: .env` still carries them; `docker-compose.override.yml` (`248d36f15`) drops `TAILSCALE_AUTHKEY`, `AGENTBOX_NSEC` and `AGENTBOX_PRIVKEY_HEX` from `environment:`; `env_file: .env` still carries them; `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 31a54c2f3..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The instruction mounts, `instructions-project` and `cred-sync` are untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2119-remove-retired-outliner-ontology-runtime.md b/docs/adr/ADR-2119-remove-retired-outliner-ontology-runtime.md index 771b22317..05d2bb80e 100644 --- a/docs/adr/ADR-2119-remove-retired-outliner-ontology-runtime.md +++ b/docs/adr/ADR-2119-remove-retired-outliner-ontology-runtime.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: f7465412de3d0d7a25fc1b6b2c8a72775490616d +verified_commit: dc91e092ab646b4a825805b8229602ac8b15bad3 verified_paths: [flake.nix, lib/ontology-tools.nix, services/ontology-tools, services/agentbox-mcp/src/web_summary, skills/ontology-core, skills/ontology-enrich, dream.config.json] owner: jjohare review_trigger: commit and rebuild the image; or introduce a corpus writer or output format @@ -68,3 +68,29 @@ Tripped by the `sidestr:dreamlab-txbt4` seal. `flake.nix` adds `sidechainChains` - the manifest validator is valid; - `check-manifest-catalogue` passes; - `tests/config/sidechain-genesis.test.sh` passes 7/7 and `sidechain-producer-gates.test.sh` 7/7. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). Nothing of the retired outliner runtime returns, and the decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +Nothing of the retired outliner runtime returns. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. diff --git a/docs/adr/ADR-2120-codex-daemon-package-volume.md b/docs/adr/ADR-2120-codex-daemon-package-volume.md index 535fc1d47..57297041a 100644 --- a/docs/adr/ADR-2120-codex-daemon-package-volume.md +++ b/docs/adr/ADR-2120-codex-daemon-package-volume.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: f7465412de3d0d7a25fc1b6b2c8a72775490616d +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [agentbox.sh, config/entrypoint-unified.sh, flake.nix, docker-compose.yml, scripts/refresh-compose.sh, tests/config/compose-persistence.test.cjs, tests/config/refresh-compose.test.cjs] owner: jjohare review_trigger: commit verification and rebuild; or change Codex daemon packaging @@ -77,3 +77,33 @@ Tripped by the `sidestr:dreamlab-txbt4` seal. `flake.nix` adds `sidechainChains` - the manifest validator is valid; - `check-manifest-catalogue` passes; - `tests/config/sidechain-genesis.test.sh` passes 7/7 and `sidechain-producer-gates.test.sh` 7/7. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `flake.nix` changed only as follows. W0 (`8070c1010`, `6a433e6b3`): `root` loses its `devuser` member, and `[program:docker-read-proxy]` is added (root start, drops to 65534). W1 (`b8c66625a`, `055c06ff6`): role passwd and group lines are appended from `config/role-accounts.json`, `supervisord.roles.conf`, `role-secrets.tsv` and `role-accounts.json` are derived beside the unchanged `supervisord.conf`, and a root-owned `/run/secrets` tmpfs is added (ADR-2122). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). `docker-compose.yml` gains one tmpfs line. The Codex package volume and its exec mount are untouched. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). `flake.nix` changes only `[program:tailscale-up]` (a `TAILSCALE_AUTHKEY_FILE` branch that passes `--authkey=file:`; the original branch is unchanged and is the one taken with the flag off) and the `[program:nostr-gateway]` comment. The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `agentbox.sh` gains W9's `browsercontainer_fetch_podkey` before sidecar builds and a `browsercontainer podkey` subcommand (`7c10e502b`); nothing outside the browsercontainer verbs changes. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. `flake.nix` gains three things: W5's read-only bake of the sidestr upstream (`lib/sidestr-upstream.nix`, linked at `/opt/agentbox/sidestr/upstream` under `[sidechain].enabled`; `e103f81a7`); the isolated supervisor config renamed `/etc/supervisord.roles.conf` (`760ed01e4`); and a `[program:serve-identity]` block that prints one line and exits 0 while `[security].role_isolation` is off (`b49c62249`). +The Codex package volume is untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off; `flake.nix` (`dc91e092a`) gains one let-binding, `roleIsolationBaked = securityCfg.role_isolation or false`, and its inline `/etc/sudoers` lines become a call to `config/bake-devuser-privilege.sh` with that flag; with the flag off (the shipped value) the baked `/etc/group`, `/etc/sudoers` and `/etc/sudoers.d/devuser` are byte-identical (RC-X1-07). Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 32cedf992..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The Codex package volume and its exec mount are untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2121-factrail-implements-jev-compaction.md b/docs/adr/ADR-2121-factrail-implements-jev-compaction.md index 596e9cc83..da9a25580 100644 --- a/docs/adr/ADR-2121-factrail-implements-jev-compaction.md +++ b/docs/adr/ADR-2121-factrail-implements-jev-compaction.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: e020264b54c6872ca98995c1adda18b8451a39af +verified_commit: f93586b9e52fda0d0b367881e2d2ff3014509faf verified_paths: [lib/factrail.nix, lib/lockfiles/factrail-57ac25b5.Cargo.lock, lib/claude-code-binary.nix, config/entrypoint-unified.sh, config/claude-plugins/.claude-plugin/marketplace.json, scripts/factrail-store-migrate.mjs, tests/config/factrail-store-migrate.test.mjs, tests/config/factrail-projection.test.sh, schema/agentbox.toml.schema.json] owner: jjohare review_trigger: a factrail rev bump in lib/factrail.nix, the end of the post-rebuild residency soak, a Claude Code function-hook API change, or a decision to train a local judge on recorded Jev decisions @@ -163,3 +163,29 @@ Re-verified by `git diff f7465412d..6db0ffc8d -- `; no re-implem Tripped by ADR-2097 (the rail keyed by chain). `schema/agentbox.toml.schema.json` changes only `payments.properties.sidestr` (`producer_url` optional, `mirror_url` added). Nothing else this record governs is touched, and the decision holds unchanged. Re-verified by `git diff 6db0ffc8d..e020264b54c6872ca98995c1adda18b8451a39af -- `; no re-implementation was needed. + +## Re-verification — 2026-10-03 (`055c06ff69b2f53bf38a67d254c048bb03599fc8`) + +Tripped by custody X-1 step 1 (W0 `custody/w0-bypasses` and W1 `custody/w1-role-accounts`). `config/entrypoint-unified.sh` changed only as follows. W0: the root boot `PATH` is store-only, the workspace cargo bin is appended for devuser shells only, Stage B is one-shot and the Docker socket is gated. W1: the role-custody lib is sourced, and the `/run/secrets` and supervisor-config steps are gated on `[security].role_isolation`; flag-off statements are verbatim (ADR-2122). The schema gains only `security.role_isolation`. The factrail projection and `[features.jev_compaction]` are untouched. The decision holds. +Re-verified by `git diff 0919dc39a..055c06ff6 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody X-1 step 1, W2 (`custody/w2-env-scrub`: `0965a9c8c`, `042115499`, `275e12356`; bypass 3, ROLE secrets out of PID 1's environment). `config/entrypoint-unified.sh` gains the W2 role-env block (`_AB_ROLE_ENV_VARS`, `_ab_role_env_capture` before the identity bootstrap, `_ab_role_env_scrub` on the line before `exec supervisord`, `_ab_role_key_file_own` in Phase 5c). Every function returns at its first line unless `[security].role_isolation` is on, so the flag-off boot is unchanged (RC-X1-06 compares the environment handed to supervisord byte for byte). The decision holds. +Re-verified by `git diff 055c06ff6..275e12356 -- `. No re-implementation was needed. The image is unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Tripped by the custody integration (`custody/integration`: W0, W1, W5, W3, W7a, W8, W2, W9 and +the integration resolutions, ADR-2122). Since `275e12356` the governed paths changed as follows. `config/entrypoint-unified.sh` changed in a comment and a log line (the config's new name, `760ed01e4`). W2's role-env capture now runs `mkdir -p` on the secrets root and `mkdir -m 0700` on the role dir (`3b5412963`). That fixes shellcheck SC2174 and behaves the same. Both changes are reached only with the flag on. +factrail's pin and Jev compaction's wiring are untouched. The decision holds. Re-verified by `git log 275e12356..3b5412963 -- ` +and the integration gates. Nix was not evaluated in this container; the image is unverified +until the owner's rebuild. + +## Re-verification — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`, custody W10) + +Tripped by the W10 gap fixes on `custody/integration`. `config/entrypoint-unified.sh` (`dc91e092a`) gains `_ab_devuser_privilege_check` and its call after the docker-socket check; it reads files only and is a no-op with `[security].role_isolation` off. Nothing this record governs changes meaning. The decision holds. Re-verified by `git log 3b5412963..dc91e092a -- `. Nix was not evaluated in this container. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e` (custody W2b and W4: the at-rest migrate/revert and the sidechain state move). `config/entrypoint-unified.sh` changes only in three custody blocks (ADR-2122, design 3.2). (1) A new at-rest step before Phase 3: `ab_custody_migrate` when `[security].role_isolation` is on, otherwise `ab_custody_revert`, which changes nothing on a volume that was never migrated (`tests/config/role-custody-migrate.test.sh` shows the stat set, ctime included, byte-identical). (2) Under the flag only, the volume-root chown loop skips `/var/lib/agentbox/secrets`. (3) After the identity bootstrap, the identity file goes to ab-identity 0400 under the flag; with the flag off, the devuser 0600 statements are unchanged. The factrail projection and `[features.jev_compaction]` are untouched. The decision holds. Re-verified by `git log dc91e092a..f93586b9e -- `. diff --git a/docs/adr/ADR-2122-role-service-accounts-run-secrets-and-the-identity-port.md b/docs/adr/ADR-2122-role-service-accounts-run-secrets-and-the-identity-port.md new file mode 100644 index 000000000..fcda9fd6b --- /dev/null +++ b/docs/adr/ADR-2122-role-service-accounts-run-secrets-and-the-identity-port.md @@ -0,0 +1,518 @@ +--- +id: ADR-2122 +title: Role service accounts, /run/secrets, and the identity port +date: 2026-10-03 +decision_status: proposed +implementation_status: partial +activation_status: inactive +supersedes: [] +superseded_by: [] +verified_commit: 5c787fedc999001c5bbe275a8111a21276a5abbc +verified_paths: [config/role-accounts.json, services/agentbox-manifest/src/role_accounts.rs, services/agentbox-manifest/src/main.rs, lib/agentbox-manifest.nix, config/lib/role-custody.sh, config/entrypoint-unified.sh, flake.nix, docker-compose.yml, agentbox.toml, setup/agentbox.default.toml, schema/agentbox.toml.schema.json, management-api/lib/system-manifest.js, tests/config/role-isolation-supervisor.test.sh, tests/config/role-secrets-delivery.test.sh, tests/config/role-isolation-boot.test.sh, tests/config/fixtures/role-isolation/supervisord.conf, config/custody/env-classes.json, scripts/ci/env-secret-inventory.js, management-api/lib/role-secret.js, services/nostr-pod-bridge/src/role_secret.rs, services/nostr-pod-bridge/src/bootstrap.rs, tests/runtime-contract/RC-X1-06.sh, config/custody/identity-port-acl.json, services/nostr-pod-bridge/src/identity_port/mod.rs, services/nostr-pod-bridge/src/identity_port/server.rs, management-api/lib/pod-signer.js, scripts/activation/role-isolation-rehearsal.sh, scripts/activation/role-isolation-rehearsal.host.sh, tests/config/role-isolation-rehearsal.test.sh, config/bake-devuser-privilege.sh, tests/runtime-contract/RC-X1-07.sh, tests/security/compose-role-env.test.mjs, docker-compose.override.yml, docker-compose.hp.yml, tests/config/role-custody-migrate.test.sh, config/sidechain/run-producer.sh, config/sidechain/run-faucet.sh, config/sidechain/mirror-sync.sh] +owner: jjohare +review_trigger: the role-isolation rehearsal (scripts/activation/role-isolation-rehearsal.sh) passing or failing on a rebuilt image; a new secret-bearing supervisor program; a new [sidechain.] chain; a change to the host docker gid; the identity port's consumer cutover (W3b: JunkieJarvis, the mirror hook, the gateway, dream-engine); a change to config/custody/identity-port-acl.json +repo: agentbox +domain: SECURITY-profiles +lineage: ADR-2027 (secret custody; role accounts become its custodians, owner Q3), ADR-2101 (named operations only, no generic sign port), ADR-2078 (pods signer source moves to the port under the flag), ADR-2066 (pod signing key path), ADR-2064 (fail-closed signing preserved), ADR-2020 (gated, identical in effect when off), ADR-039 (apply classes) +--- + +# ADR-2122 — Role service accounts, /run/secrets, and the identity port + +## Context + +Every secret in this container reaches every process. `.env` feeds PID 1's environment and +supervisord passes it to every child. The sovereign key sits in a devuser file at +`/run/secrets/nostr.key`. Producer keys, parent credentials and treasury keys are devuser-owned +on the secrets volume or the workspace bind. `/run/secrets` is a directory on a `/run` tmpfs +that devuser owns, so devuser can rename it. Same-uid enforcement in the colloquy publisher is +described in its own source as "discipline, not enforcement". The custody design for swarm +`custody-isolation-2026-10-03` (X-1 step 1) sets out why, with evidence, in its §1. + +Three bypasses made any in-container boundary void. Workstream W0 closed them (branch +`custody/w0-bypasses`, tests RC-X1-01..05). It made the root boot `PATH` store-only and Stage B +one-shot, took devuser out of group root, and gated the Docker socket behind the flag with a +GET-only read proxy for devuser. + +## Decision + +One Unix account per secret-holding role, baked into the image, and one flag that switches the +supervised programs onto them at boot. + +### 1. Accounts (design §2.2) + +`config/role-accounts.json` is the single source. `flake.nix` appends its passwd and group lines +(`agentbox-manifest role-accounts passwd|group`). It also ships the table at +`/etc/agentbox/role-accounts.json`. Range 960–979. uid = gid. Each role has its own primary group +with **no members**. devuser joins no role group, and no role is in group root or devuser. +Home is `/run/secrets//home`. Shell is `/sbin/nologin`. + +| Role | uid | Programs (isolated config) | Secrets delivered to `/run/secrets//` | +|---|---|---|---| +| `ab-identity` | 960 | `nostr-relay` | `nostr.key` ← `$AGENTBOX_BRIDGE_SK`; `AGENTBOX_PRIVKEY_HEX`, `AGENTBOX_NSEC`, `JUNKIEJARVIS_PRIVKEY_HEX`, `CONCIERGE_PRIVKEY_HEX` ← the same-named PID-1 variables | +| `ab-gateway` | 961 | `nostr-gateway` | none (signs through the port; AoE bearer through the share, design §2.6) | +| `ab-ingress` | 962 | `nip98-proxy` | `NIP98_PROXY_ALLOW_BEARER`, `NIP98_PROXY_SESSION_SECRET` ← PID-1 variables | +| `ab-spend` | 963 | — (Q6: W5 deferred) | none in step 1; account only | +| `ab-sidestr-dreamlab` | 964 | `sidestr-producer` | `signer.key` ← `secrets/sidestr-dreamlab.key`; `parent.credential` ← `secrets/sidestr-tbtc4.cookie` | +| `ab-faucet-dreamlab` | **966** | `sidestr-faucet` | `treasury.key` ← the program's `SIDESTR_FAUCET_KEY` (`[sidechain].faucet_key_file`) | +| `ab-sidestr-dreamlab-txbt4` | 967 | `sidestr-producer-dreamlab-txbt4` | `signer.key` ← `secrets/sidestr-dreamlab-txbt4.key`; `parent.credential` ← the program's `SIDESTR_PARENT_COOKIE` (`parent_credential_file`) | +| `ab-faucet-dreamlab-txbt4` | 968 | `sidestr-faucet-dreamlab-txbt4` | `treasury.key` ← the program's `SIDESTR_FAUCET_KEY` | + +**965 is reserved.** It is the host docker group that `docker-compose.override.yml` adds through +`group_add`. A role whose gid is 965 would be in the Docker socket's group, and the Docker daemon +is root on the host. The design's "per chain from 964 upwards" would have put +`ab-faucet-dreamlab` exactly there. The table validator refuses any reserved id, and a test +cross-checks every compose `group_add` gid. + +management-api, dream-engine and the agent plane (aoe, tmux) stay devuser, as the design's §1 +inventory decides. JunkieJarvis is a key in `ab-identity`'s keyring, not an account. + +### 2. Two supervisor configs, one flag (design §2.3, §3.1) + +`/etc/supervisord.roles.conf` is a pure function of today's rendered `/etc/supervisord.conf` +(`agentbox-manifest role-accounts isolate`). In each role program it changes exactly two lines: + +- `user=devuser` becomes `user=`; +- `environment=` gains or rewrites `HOME`, `AGENTBOX_SECRETS_DIR` and each secret's path variable + (`AGENTBOX_BRIDGE_SK_FILE`, `SIDESTR_KEY`, `SIDESTR_PARENT_COOKIE`, `SIDESTR_FAUCET_KEY`), all + pointed at `/run/secrets//`. + +A secret's at-rest source is the program's own `environment=` value when it sets the variable, so +manifest paths are never duplicated. The same run emits the delivery plan +`/etc/agentbox/role-secrets.tsv`. The build fails if a secret-bearing program has no role, if a +role program is not `user=devuser` today, or if a source is not a literal path. + +`[security].role_isolation` (default `false`) is **boot-class**. Accounts, both configs, the plan +and the mount ship in every image, and the entrypoint picks at its final `exec`. The image that +first carries this change needs one rebuild. + +### 3. Secret delivery (design §2.4) + +`/run/secrets` is its own tmpfs (`mode=711,uid=0,gid=0,noexec,nosuid,nodev`) in both modes, so +devuser cannot rename it. With the flag on, the entrypoint's root phase does four things: + +1. Keeps the mount `root:root 0711` and records `degraded:secrets-mount` if it is not a mount + point. +2. Moves W0's Stage-B guard to `/run/secrets/.root-guard`. With the flag off the guard stays in + `/tmp/.agentbox-root`, because the flag-off boot hands `/run/secrets` to devuser. +3. Just before `exec`, runs the plan (`config/lib/role-custody.sh`, `ab_role_secrets_deliver`): + `/` at `0500`, `/home` at `0700`, each file at `0400`, all owned by the role. + File sources are copied only if they are regular, non-symlinked and at most 64 KiB. + Classified variables are written through the `printf` builtin and then **unset**, so + supervisord never inherits them. +4. Writes `ok:` or `degraded:secrets-delivery` to `/run/secrets/role-isolation.state`. + +Phase 5c writes no devuser `nostr.key`. No value is printed or put on argv. Every failure is +logged with a `ROLE-ISOLATION-` marker and the boot continues: a role whose secret is missing +fails closed under supervisord. An image that cannot honour the flag boots as flag-off and logs +`ROLE-ISOLATION-UNAVAILABLE`. + +### 4. The identity port (design §2.5) — decided here, built by W3a + +`nostr-pod-bridge`, running as `ab-identity`, becomes the only holder of the sovereign, operator, +per-agent DID and JunkieJarvis keys. It serves a unix socket under `/run/secrets/port/` that +authorises callers by `SO_PEERCRED` uid. It offers a closed set of named operations and never a +generic `sign(event)` (ADR-2101). The set W3 built, the socket's final location +(`/run/secrets/ab-identity-port/identity.sock`) and its group are recorded under +"The identity port as built (W3) — 2026-10-03" below. Each decision gets a +content-free receipt. The crypto stays in `nostr-bbs-core` and `k256`. No new key format, event +kind or published crate is introduced. + +### Invariants + +| Boundary | Invariant | +|---|---| +| `ab-identity` + port | No process other than `nostr-pod-bridge` holds the sovereign, operator, per-agent or JunkieJarvis secret. Every signature is a named, uid-admitted, receipted operation. | +| `ab-sidestr-` | A chain's signer key and parent credential are readable only by its producer. | +| `ab-faucet-`, `ab-spend` | Value-bearing keys are reachable only by the process that enforces the spend policy. | +| `ab-ingress` | The LAN-door break-glass credential cannot be lifted by a co-resident agent. | +| `/run/secrets` mount | Only root creates, replaces or renames secret paths after boot. | +| devuser ∉ {root, docker, any role} | devuser cannot reach a credential by becoming root, in the container or on the host. | +| devuser ∉ wheel, no sudoers grant (W10, 2026-10-03) | With the flag on at build, devuser is in neither wheel nor root and no sudoers line grants it `NOPASSWD`; an image built with the flag off and booted with it on reports `degraded:devuser-sudo`. | + +## Acceptance + +The decision is accepted when `scripts/activation/role-isolation-rehearsal.sh` (W6a, branch +`custody/w6a-rehearsal`) and its host half pass on a rebuilt image with the flag on, checks +(a)–(f) of design §4, and land their receipts as the scripts write them: +`docs/estate-closeout/x1-rehearsal-.json` (container half) and +`docs/estate-closeout/x1-rehearsal-host-.json` (host half), both against +`docs/estate-closeout/schema/x1-rehearsal.schema.json`. (Corrected 2026-10-03: this sentence +first named a dated subdirectory, which neither script writes.) +`degraded:docker-socket` and `degraded:secrets-*` are failures. `activation_status` moves only +on that receipt. Peer agent messages are not approval. + +### 3a. The environment scrub (W2, bypass 3) — added 2026-10-03 + +compose `env_file` puts `.env` into PID 1 and supervisord hands its environment to every +child, so delivery to files is not enough on its own: the variables must also leave PID 1. +The ROLE set is a checked-in table, `config/custody/env-classes.json`. Every environment +variable name that the entrypoint, `config/lib/role-custody.sh`, `flake.nix`, the compose files, +management-api and the role-secret consumers read is in exactly one class: + +- **ROLE (11):** `AGENTBOX_PRIVKEY_HEX`, `AGENTBOX_NSEC`, `AGENTBOX_BRIDGE_SK`, + `OPERATOR_NOSTR_PRIVKEY`, `JUNKIEJARVIS_PRIVKEY_HEX`, `CONCIERGE_PRIVKEY_HEX`, + `AGENTBOX_AGENT_PRIVKEY_HEX` and `AGENT_PRIVKEY_HEX` (all `ab-identity`); + `NIP98_PROXY_ALLOW_BEARER` and `NIP98_PROXY_SESSION_SECRET` (`ab-ingress`); + `TAILSCALE_AUTHKEY` (`root`). +- **DEVUSER_CLASS (35):** accepted exceptions, each with a reason. These are the Q8 provider + and devuser credentials, `BRIDGE_TOKEN`, `MANAGEMENT_API_KEY` and `VAULT_NOSTR_SECRET` + (a ROLE candidate for W3b). +- **NON_SECRET:** everything else. A credential-shaped name needs an exact entry. + +`scripts/ci/env-secret-inventory.js --check` fails on any of the following: + +- an unclassified name; +- a name in two classes; +- a secret-shaped name classified by pattern; +- drift between the ROLE set and the entrypoint's `_AB_ROLE_ENV_VARS`; +- drift from this record's `config/role-accounts.json`. Every `from_env` there is ROLE with the + same role, and the four ROLE vars the plan lacks are listed as `w2_only`. + +Under the flag, the entrypoint does three things: + +1. **`_ab_role_env_capture` runs before the identity bootstrap.** Each ROLE variable present is + written to `/run/secrets//` at `0400` (the same artefact §3's plan writes). Its + `_FILE` is exported and the variable is unset. It runs this early because under the flag + the bootstrap must not read a bare operator pin. §3's `env` rows then find these variables + already unset and skip them. +2. **`identity.env` is public-only.** It carries no `AGENTBOX_NSEC` and no + `AGENTBOX_BRIDGE_SK`. The bootstrap writes the relay key itself to + `/run/secrets/ab-identity/nostr.key` (`create_new`, `0400`). The secret otherwise lives only + in the identity file. +3. **`_ab_role_env_scrub` runs on the line before `exec`.** It unsets anything re-exported since + and logs `ROLE-ISOLATION-LEAK `. + +Readers use one loader per language: `management-api/lib/role-secret.js`, re-exported by +`agent-identity.js`, and `nostr-pod-bridge::role_secret`. dream-engine's `load_signing_key` +keeps the same contract. The precedence is: + +1. `_FILE`; +2. `$AGENTBOX_SECRETS_DIR/` (§2's isolated config); +3. the bare variable, only with the flag off. + +Under the flag, a bare ROLE variable that is present at all is logged as `ROLE-ISOLATION-LEAK` +and ignored. Flag off, the environment handed to supervisord is byte-identical (RC-X1-06). +`tailscale-up` now gets `--authkey=file:`, which closes the argv finding below. + +## Status: what exists and what is owed + +**Built (W0 + W1 + W3, staged, unverified in an image).** + +- The three bypasses are closed (W0). +- The accounts, the mount, the derived isolated config and plan, the delivery and the flag + wiring are in place (W1). +- The identity port and the pods signer's cutover to it (W3; see the dated section below). +- With the flag off the boot is today's. `tests/config/role-isolation-boot.test.sh` executes the + real entrypoint blocks and shows `exec supervisord -c /etc/supervisord.conf`, no delivery and no + state write. + +**Owed before the flag may be turned on.** Under the flag today, role programs fail closed. + +- ~~**W2 custody.** It locks the at-rest volumes, with an idempotent migrate/revert.~~ Built + (W2b, `f93586b9e`; see the dated section below). It is unproven in an image. **The + legacy copies stay readable to devuser by design (Q5)**: the workspace treasury keys and + `zone-keys.json` remain on the bind until a manual purge. +- **W2 other items.** + - ~~A public-only `identity.env`.~~ Done (§3a, `custody/w2-env-scrub`). + - The aoe-share copy for `ab-gateway` and `ab-ingress`. Both still point at devuser's + `serve.url`. + - Ownership of the relay data dir `/var/lib/nostr-relay` for `ab-identity`. + - The `role-exec` launcher: environment clear, store-only `PATH`, umask 077. It will change + role programs' `command=` lines, and the drift test's allowed set must widen to `command=` + explicitly when it lands. +- **W3b.** The rest of the consumer cutover: JunkieJarvis, the mirror hook, the gateway and + dream-engine. The pods signer moved in W3. +- **Readers of the delivered files.** + - ~~`nip98-proxy` reads `NIP98_PROXY_ALLOW_BEARER` and `NIP98_PROXY_SESSION_SECRET` from its + environment only.~~ Done (§3a): it reads `$AGENTBOX_SECRETS_DIR/` as `ab-ingress`. + Recorded fact: the bearer is still the same value as `BRIDGE_TOKEN`, which stays + devuser-class until the Q4 split (ADR-2027). + - The gateway now reads the operator key file-only under the flag (§3a). As `ab-gateway` + it holds no key by design, so it signs nothing until the identity port (W3a/W3b). +- ~~**W4.** The sidestr upstream baked from Nix, and the state moved off the workspace.~~ The + bake is W5; the state move is built (W2b section below). The `ab-sidestr-read` group was not + needed (deviation below). + +### Deviations from the design, with reasons + +- **The isolated config was first built as `supervisord.isolated.conf`**, per the lead's brief. + At integration (2026-10-03) it was renamed to the design's `/etc/supervisord.roles.conf`, the + name W6a's rehearsal probes (queen's disposition, design §13). +- **Role homes are under `/run/secrets//home`**, not `/var/lib/agentbox/home/`. + devuser owns `/var/lib/agentbox` (Phase 1 chowns it), so it could rename a home placed there. +- **uid 965 is skipped**, as described under §1. +- **There is no `role-exec` wrapper yet.** The contract that only `user=` and `environment=` + differ is what makes this step reviewable. +- **The `ab-sidestr-read` and `ab-aoe-share` groups are not created.** Their consumers are W4 + and W2, and devuser joins no group in this step. W4 gives the mirror read access through + devuser's own group instead: each chain's state dir is `ab-sidestr-:devuser 2750`, the + precedent `/var/lib/agentbox/events/sign` set. No new group, and no new member of any group. +- **`identities/` is `root 0711` under the flag, not `0700`.** The pay402/Hitch spend keys live + there (`management-api/lib/sidestr-spend-key.js`) and stay devuser-class until the spend port + (Q6). devuser still cannot list the dir, and `agentbox-core.json` in it is `ab-identity 0400`. + `secrets/` is `root 0700` as designed. +- **The catalogue apply class is `boot`, not `rebuild`.** The flag does not change the image's + composition (ADR-039's definitions). + +### Recorded facts + +- `11ed64…` keeps read access to the forum's epoch-1 zone secrets. It cannot be undone. Re-sealing + is a named follow-up (queen's disposition on Q16). +- `[program:tailscale-up]` passed `TAILSCALE_AUTHKEY` on argv (pre-existing, root program). + Resolved by W2 (§3a). Under the flag the key is delivered root-only to + `/run/secrets/root/TAILSCALE_AUTHKEY` and passed as `--authkey=file:`, a form + tailscale 1.102 documents. With the flag off the original branch runs unchanged. +- `[program:docker-read-proxy]` (W0) carries a TODO to move into this registry. It needs no + account: it holds no secret and must start as root to join the socket's group before dropping to + 65534. The comment is left in the supervisor text so that today's config stays byte-identical, + and it goes at the next edit of that block. + +## The identity port as built (W3) — 2026-10-03 + +Folded in at integration from `custody/w3-identity-port` (`a44ea413f`, `9e87ae402`, `26fc543d2`, +`57ea350a9`). It narrows §4 and does not widen it. + +- **Program.** `nostr-pod-bridge serve-identity`, `[program:serve-identity]` in today's config as + `devuser`. With the flag off it prints one line and exits 0, like `docker-read-proxy`. With the + flag on, `role-accounts isolate` runs it as `ab-identity`. `env -i` passes only the flag, the + ACL path, the key dir (`AGENTBOX_SECRETS_DIR`, i.e. `/run/secrets/ab-identity`), the socket path + and gid, and the receipt dir, so PID 1's inherited `.env` never reaches it. +- **Operations** (`acl.rs` `OPERATIONS`, closed): `pubkey`, `nip98` (URL-prefix allowlist), + `sign_event` (granted kinds only), `forum_event`, `nip42_auth` (allowlisted relays) and + `mirror_key`. Kinds 27235, 22242, 31400–31405 and 38414 cannot be granted through `sign_event`; + the loader refuses such an ACL. Zone sealing is not an operation. `dm_unwrap` stays inside the + relay process and is refused at the port. +- **Authorisation.** `config/custody/identity-port-acl.json`, keyed by `SO_PEERCRED` uid: devuser + (1000) and `ab-gateway` (961). The socket's group only narrows who can connect. +- **Keys.** `core` is the file `AGENTBOX_PRIVKEY_HEX` and `junkiejarvis` is + `JUNKIEJARVIS_PRIVKEY_HEX`, both in `/run/secrets/ab-identity/` as W1's plan writes them (byte + for byte; the live-mirror child derives from the supplied hex, `src/mirror_key.rs`). W3 first + named them `core.key` and `junkiejarvis.key` under `/run/secrets/identity/`. Integration kept + W1's names because W2's readers resolve `$AGENTBOX_SECRETS_DIR/`. +- **Socket.** `/run/secrets/ab-identity-port/identity.sock`, 0660, group `ab-identity-port` (969). + The directory is `ab-identity:ab-identity-port 0750`. W1's delivery creates it from a `sockdir` + plan row inside the root-owned secrets mount. `serve-identity` refuses a parent that is + world-writable without the sticky bit, or one owned by neither root nor itself. +- **Receipts.** One content-free JSONL line per decision under `/var/lib/agentbox/events/sign/` + (`ab-identity:devuser 2750`, from a `dir` plan row; the port cannot create it under the + devuser-owned parent). +- **Consumer.** `management-api/lib/pod-signer.js` signs pods NIP-98 through `sign-request nip98` + when the flag is on (or `sign_source = "identity-port"`). Then the port is the only source, and + a refusal, an absent port or a failure to answer becomes `SigningUnavailable` before a byte is + sent (ADR-2064 unchanged). + +## Integration resolutions — 2026-10-03 + +Applied on `custody/integration` under the queen's dispositions (design §13). Each one is its +own commit with the reason. + +- **One supervisor config name.** `/etc/supervisord.roles.conf` everywhere: the flake, + `role_accounts.rs`, the entrypoint and W1's tests. +- **One role table.** The rehearsal (both halves) reads `/etc/agentbox/role-accounts.json` in + this record's schema together with `role-secrets.tsv`. Its derived numbering is gone, because it + put a faucet at 965, the host docker gid. A missing table, a missing plan or any role uid in + `reserved_ids` exits 1. +- **The socket group comes from the table.** `config/role-accounts.json` gains `groups` + (`ab-identity-port`, gid 969, members devuser, `ab-identity` and `ab-gateway`; the owner is a + member because chgrp needs membership) and `dirs`. `isolate` exports + `AGENTBOX_IDENTITY_SOCK_GID` to `ab-identity`'s programs, and the plan gains `sockdir` and `dir` + rows. Role groups still have no members. +- **Deviation: the socket is not under `/run/agentbox`.** The disposition asked for a root-owned + `/run/agentbox`. `/run` is a devuser-owned tmpfs, so devuser could rename a root-owned + `/run/agentbox` away and plant a socket. That is the reason `/run/secrets` has its own mount + (§"Deviations"). Root-owning it would also break `bootstrap-seal` (`bootstrap.done`, the + `/ready` signal) and `teammate-gc` under the flag. The socket therefore sits inside the + `/run/secrets` mount. + +## Correction to the integration disposition: the socket's directory — 2026-10-03 + +The queen's integration disposition (design §13) placed the identity socket in a root-owned +`/run/agentbox`. That does not hold, because `/run` is devuser's. It is a tmpfs mounted +`uid=1000,gid=1000` (`docker-compose.yml:109`, `flake.nix:3273`). A non-root owner of a +directory can rename any entry in it, so devuser can move a root-owned `/run/agentbox` aside and +plant a directory with its own socket. Ownership of the entry does not matter; ownership of the +parent does. `/run/secrets` is safe for the same reason in reverse: it is its own tmpfs, +`uid=0,gid=0,mode=711` (`docker-compose.yml:110`, `flake.nix:3278`), so it is a mount point +devuser can neither rename nor write. The socket's directory, `/run/secrets/ab-identity-port`, +sits inside it. Root-owning `/run/agentbox` would also have broken two devuser writers under the +flag: `bootstrap-seal`, whose `/run/agentbox/bootstrap.done` is the `/ready` signal +(`config/seal-bootstrap.sh:15`), and `teammate-gc`. + +The queen accepted the correction. The rehearsal now tests the property. In check (a), devuser +tries to rename the socket directory and to create an entry beside it, and both must be refused. +A directory that is renamed is moved straight back. `tests/config/role-isolation-rehearsal.test.sh` +case 17 puts the socket directory under a devuser-writable parent, the `/run/agentbox` case, +and shows (a) failing with the directory restored. The PASS fixture shows both attempts refused. + +## Pointer: the browser sidecar's key (W9) — 2026-10-03 + +`custody/w9-sidecar-podkey` gives the Chrome sidecar Podkey, pinned by commit, run, artefact and +zip sha256 in `browsercontainer/podkey.pin`, and keeps the profile on the +`browsercontainer-profile` volume. The key Podkey holds is K_browser, the sidecar's identity in the G-5 split. It +lives in the sidecar, not in this container, so no role account here holds or delivers it, and +nothing in this record's table changes. The custody of that key and the pin's forward-only rule +are recorded in `browsercontainer/README.md` ("Podkey and the persistent profile"). + +## At-rest custody and the sidechain state move (W2b, W4) — 2026-10-03 + +Design 3.2–3.4 was not built when the docs pass ran, and check (a) of the rehearsal failed with +the flag on. It is built in `f93586b9e`. + +- **The registry is derived from the role table, not listed by hand.** `role-accounts isolate` + writes two extra row kinds into `role-secrets.tsv`. `atrest ` is + written for every resolved file secret and for each role's `at_rest` entries + (`agentbox-core.json`; `zone-keys.json`, whose legacy copy is on the workspace). + `atrestdir ` is written for `at_rest_dirs`. The validator requires every at-rest + path to sit directly inside a declared dir, so the migrate step never re-owns a workspace file. + The treasury keys' `source` is now the volume copy + (`/var/lib/agentbox/secrets/sidestr-faucet-.key`), and the workspace path is its + `legacy`. While a program's `environment=` still names the legacy path (flag-off config), the + isolated plan reads the volume copy. +- **Migrate (flag on), `ab_custody_migrate`.** It runs after the `/run/secrets` prep and the + volume-root chown, before `nostr-pod-bridge bootstrap`. For each row where the canonical copy + is absent, it copies the legacy twin once: umask 077, fsync, size and in-memory byte compare, + rename. It never touches or deletes the legacy copy. It refuses a canonical copy that is a + symlink, hard-linked, empty or oversized, or that already existed on the first migration and + differs from its legacy twin: such a copy is not re-owned and not delivered. Each accepted copy + goes to its role at `0400`; `secrets/` becomes `root 0700` and `identities/` `root 0711`. + Pre-flag owner and mode are recorded once in `secrets/.role-custody.modes` (root 0600, trusted + only when root-owned). The names and statuses go to `/run/secrets/role-isolation.migrated` + (0644, paths only). Every step is fail-open and counted. +- **Revert (flag off), `ab_custody_revert`.** It hands each recorded path back to devuser at its + pre-flag mode, or to root when root owned it before. With no trusted record it changes + nothing, so a volume that was never migrated keeps a byte-identical stat set. +- **W4 state.** A role's HOME is on the tmpfs, so the workspace defaults restarted the chain and + emptied the faucet ledger on every boot. Under the flag the producer, mirror and faucet now + default to `/var/lib/agentbox/events/sidestr/` and `faucet-/faucet.json`. These + are `dirs` rows, owned by the role with group devuser 2750, and `seed` rows copy the workspace + state into them once and never over newer state. With the flag off, the producer and faucet + refuse to start (`CUSTODY-STATE-AHEAD`) when the custody copy has outgrown the workspace copy. + That covers rollback after blocks or grants were made under the flag; design 3.4's "no data + movement" does not hold for chain state. +- **Tests.** `tests/config/role-custody-migrate.test.sh` 31/31 (new). The rehearsal test goes from + 28 to 30: case 22 runs the real migrate, and given root ownership (a) passes every row. 22b shows + (a) failing on the same volumes without it. Delivery 30, boot 22, supervisor 23, manifest + cargo 121, RC-X1-01..07 green, env-secret-inventory PASS. + +Still owed for a flag-on PASS (named, not built here): the W3b consumer cutover +(JunkieJarvis, the mirror hook, the gateway and dream-engine); the aoe-share copy for +`ab-gateway`/`ab-ingress`; the `/var/lib/nostr-relay` ownership for `ab-identity`; the +`role-exec` launcher; and the owner's rebuild with the rehearsal's host half. Under the flag, +devuser readers of `secrets/` lose it: `scripts/run-per-user-agent.cjs:125` (a management-key +fallback) and `scripts/sidechain/dell-txbt4-open-rpc.sh`, which writes there as an operator. + +## Consequences + +- The image gains 8 passwd and 9 group lines (8 role groups and `ab-identity-port`), `/etc/supervisord.roles.conf`, + `/etc/agentbox/role-secrets.tsv`, `/etc/agentbox/role-accounts.json` and an 8 MiB tmpfs. With + the flag off it behaves as before. In ADR-2020's terms, it is identical in effect but not + identical in bytes. One thing changes in both modes: `/run/secrets` becomes a mount point that + devuser cannot rename. +- A new chain or a new secret-bearing program cannot ship without a role. The build names the fix. +- Operators lose `cat`-level access to role secrets under the flag. Rotation and backup move to + `docker exec -u 0` from the host shell. + +## Verification + +At `055c06ff6`. Nix cannot be evaluated in this container. Neither the image, the compose mount +nor a boot has been exercised. + +- `cargo test` in `services/agentbox-manifest`: 161 passed, including 12 `role_accounts` tests. +- `tests/config/role-isolation-supervisor.test.sh` (20): on a real rendered config, the two + configs differ only in the 7 role programs' `user=` and `environment=` lines (14 lines), and the + 31 other programs are byte-identical. No program names another role's secret path. No at-rest + source or planted secret value survives. An unmapped secret-bearing program fails the build. The + docker gid is reserved. +- `tests/config/role-secrets-delivery.test.sh` (26): the real plan against a scratch root. Owners + come from a chown ledger because no root or user namespace is available. It checks modes, + contents, the environment scrub, idempotence, hostile sources and hostile plan rows. +- `tests/config/role-isolation-boot.test.sh` (22): the entrypoint blocks executed with stubs for + the flag off, the flag on, an unavailable image and a degraded mount. Two mutations of the gate + were caught. +- W0's `RC-X1-01..05` (50 + 6), the five entrypoint-parsing suites, + `scripts/ci/check-secret-not-in-env.sh`, `agentbox-config-validate.js` (both manifests) and + `check-manifest-catalogue.js` pass. + +**The owner's rebuild must show** (`./agentbox.sh rebuild`, host tab 6, then flag still off): + +- `getent passwd ab-identity` gives `960`. +- `findmnt /run/secrets` is a tmpfs. +- `/etc/supervisord.roles.conf` and `/etc/agentbox/role-secrets.tsv` exist. +- `supervisorctl status` matches the pre-rebuild set. +- `/run/secrets` is `devuser 0700`, and `nostr.key` is present as before. + +## Re-verification — 2026-10-03 (`275e12356319a9630846656580d497d53de3d38c`) + +Tripped by custody W2 (`custody/w2-env-scrub`, rebased onto this record's branch). §3a above +records what it adds. The entrypoint's exec block now runs `_ab_role_env_scrub` between the +config pick and `exec`. `tests/config/role-isolation-boot.test.sh` stubs the scrub and +asserts deliver → pick → scrub → exec (22/22). `flake.nix` changes only `[program:tailscale-up]` +and a comment. The §3 plan, the accounts and the isolated config are untouched, and their suites +pass: `role-secrets-delivery` 26/26 and `role-isolation-supervisor` 20/20. The image is +unverified until the owner's rebuild. + +## Re-verification — 2026-10-03 (`3b54129631067277f6363309b01cce485faa027a`, custody integration head) + +Re-stamped once at the head of `custody/integration`, after W0, W1, W5, W3 (with W6a), W7a, +W8, W2 and W9 and the integration resolutions recorded above. Since W2's stamp (`275e12356`) the +governed paths changed only by the commits those sections describe: + +- the config rename (`760ed01e4`); +- the single role table in the rehearsal (`860f58fa2`, `31aa27f7b`); +- `serve-identity`, the `ab-identity-port` group and the `sockdir`/`dir` plan rows (`b49c62249`); +- the socket-directory probe (`ab55fbf0e`); +- W3's port and pods consumer; +- W5's bake; +- the SC2174 fix (`3b5412963`). + +Suites at this head: + +| Suite | Result | +|---|---| +| agentbox-manifest cargo | 163 | +| nostr-pod-bridge cargo | 209 | +| role-isolation-supervisor | 23 | +| role-secrets-delivery | 30 | +| role-isolation-boot | 22 | +| role-isolation-rehearsal | 23 | +| RC-X1-01..06 | 14 + 16 + 3 + 17 + 6 + 30 | +| env-secret-inventory | PASS, node test 13 | +| management-api test:node | 232 pass, 2 skipped (W8's owner-pending keys), 0 fail | + +`decision_status` stays `proposed` and `activation_status` stays `inactive` until the rehearsal +passes on the owner's rebuild with the flag on (§ Acceptance). Nix was not evaluated here. + +## Re-verification — 2026-10-03 (`32cedf9925ff6de8112fb45e41de048106d0d710`, custody integration CI fix) + +Tripped by `32cedf992`, the fix for the PR's clippy and statix failures. `flake.nix` changes by one line in the `[sidechain.*]` normaliser: `parent = c.parent;` becomes `inherit (c) parent;` (statix W04), which evaluates to the same attribute set. `services/agentbox-manifest/src/role_accounts.rs` changes for clippy only: the `check` summary now prints one line per created directory with its `purpose`, the insert sort uses `sort_by_key(Reverse)` (stable, so the rendered roles config is byte-identical), and a test drops a useless `format!`. No role, uid, group, directory, mode or plan row changes. The decision holds. Re-verified by `git log 3b5412963..32cedf992 -- `. + +## Gap fixes after the diagrams pass (W10) — 2026-10-03 (`dc91e092ab646b4a825805b8229602ac8b15bad3`) + +The diagrams pass found four gaps. Each fix is its own commit on `custody/integration`, tests first. + +1. **Compose (`248d36f15`).** `docker-compose.override.yml` passed `AGENTBOX_NSEC`, `AGENTBOX_PRIVKEY_HEX` and `TAILSCALE_AUTHKEY` through `environment:`, and `docker-compose.hp.yml` passed the first two. That was a second route into PID 1 beside `env_file: .env`, which already carries the values for the §3a scrub. Both routes are removed. `tests/security/compose-role-env.test.mjs` fails if any `docker-compose*.yml` names a ROLE-class variable under `environment:`. `TAILSCALE_AUTHKEY` (ROLE, holder root) was not in the brief; the gate caught it. +2. **Rehearsal check (d) (`e54bde59e`).** Since `860f58fa2`, (d) no longer read fields from the role table. It still took `enabled` only from whether the plan delivered rows, though, and never read `[sidechain].enabled` / `[sidechain.].enabled`. `/etc/agentbox.toml` is a bind of the repo file, so it can drift from the image's build-time plan. Each producer row now carries `enabled` (from the manifest, where `[sidechain]` dominates) and `baked` (from the plan). (d) fails when the two disagree and names the rebuild. With no chain on, it records a pass. It passes against the shipped `agentbox.toml` sidechain tables. The committed receipt `x1-rehearsal-20261003T090708Z` predates both changes (commit `3cd561473`). +3. **CI (`e9f5cd6da`).** `invariants.yml` now runs RC-X1-01..05 beside -06. It also corrects its line-84 label, which claimed a contract-tests step that did not exist. `contract-tests.yml` gains that step (`tests/sovereign/role-secret.node-test.js`) and a `custody` job. The job rebuilds the `lib/nostr-pod-bridge.nix` layout at its pinned sibling revisions, then runs the whole `nostr-pod-bridge` crate (209), the node identity-port test against the built binary (any skip fails the step) and the rehearsal test. +4. **devuser's sudo route (`dc91e092a`).** This one cannot be expressed in the flake alone. `/etc` is baked into a read-only rootfs and the flag is boot-class. `flake.nix` now hands the build-time flag to `config/bake-devuser-privilege.sh`. Off, the output is byte-identical to before. On, devuser leaves the wheel and root member lists and gets no `sudoers.d` drop-in. In the entrypoint's W0 phase, `_ab_devuser_privilege_check` records `degraded:devuser-sudo` in `/run/secrets/role-isolation.state` and logs `ROLE-ISOLATION-DEGRADED devuser-sudo` naming the rebuild when the flag is on and the image was built off. This is fail loud, not fatal, like `docker-socket`, because `no-new-privileges:true` still neuters setuid. Rehearsal check (f) already refuses a sudo-capable devuser, so the flag cannot be signed off on such an image. Test: `tests/runtime-contract/RC-X1-07.sh`, 21/0. + +Consequence for activation: **turning the flag on now needs a rebuild with the flag set**, not only a restart, if devuser is to lose sudo. Section 2's "boot-class" still holds for the configs, the scrub and the delivery. The boot reports the gap rather than hiding it. + +Suites at `dc91e092a`: compose-role-env 16, role-isolation-rehearsal 28, RC-X1-01..07 14 + 16 + 3 + 17 + 6 + 30 + 21, role-isolation-boot 22, nostr-pod-bridge cargo 209, identity-port node 10, role-secret node 23, env-secret-inventory PASS (node test 13). `decision_status` and `activation_status` are unchanged. Nix was not evaluated in this container, and shellcheck was not run here. + +## Re-verification — 2026-10-03 (`f93586b9e52fda0d0b367881e2d2ff3014509faf`, custody W2b/W4) + +Tripped by `f93586b9e`, which this record's section above describes. Every governed path changed +only as listed there. `agentbox.toml` changes in its comment only, and +`config/custody/env-classes.json` adds the new lib's shell names to NON_SECRET. With the flag +off, the boot keeps today's statements apart from the revert step, a proven no-op on a volume +that was never migrated. `decision_status` stays `proposed` and `activation_status` stays +`inactive` until the rehearsal passes on the owner's rebuild. Re-verified by +`git log dc91e092a..f93586b9e -- `. + +## Re-verification — 2026-10-03 (`ad5d0b91a794ecfec40a5072de072ec86d63eaf2`, custody integration, after W2b-r) + +Tripped by `ad5d0b91a`, a shellcheck-only change to `config/lib/role-custody.sh`: two unused `type` read targets in `_ab_rc_modes_trusted` and the migrate scan become `_`, and `AB_RC_REVERTED` (a documented output of `ab_custody_revert`) keeps an SC2034 disable with its reason. Migrate, revert, delivery and the plan format are unchanged; migrate 31, rehearsal 30, delivery 30, boot 22 and supervisor 23 pass. The decision holds. Re-verified by `git log f93586b9e..ad5d0b91a -- `. + +### Re-verification — 2026-10-03 (integration, CI portability) + +`ad5d0b91a..5c787fedc` touches `scripts/activation/role-isolation-rehearsal.host.sh` only to add the test seam `HR_ASSUME_CONTAINER=1`, which forces the in-container refusal and cannot suppress it, plus two test fixtures (`5c787fedc`). Detection, the root check and every rehearsal row are unchanged; the decision holds. Re-verified by `git log ad5d0b91a..5c787fedc -- `. diff --git a/docs/adr/README.md b/docs/adr/README.md index fe05bea66..f7fecdc19 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -59,7 +59,7 @@ detect changes to files it already lists — it is structurally blind to **addit is exactly how a new crate landed unlicensed under ADR-2030 while the record still read as verified and its `review_trigger` ("any new crate under `services/`") sat unactioned. -_113 record(s). Regenerate with_ `node scripts/adr-index-gen.js docs/adr`. +_114 record(s). Regenerate with_ `node scripts/adr-index-gen.js docs/adr`. | ID | Title | Domain | Date | Decision | Impl | Activation | Supersedes | Superseded by | Owner | Repo | |----|-------|--------|------|----------|------|------------|------------|---------------|-------|------| @@ -176,3 +176,4 @@ _113 record(s). Regenerate with_ `node scripts/adr-index-gen.js docs/adr`. | [ADR-2119](ADR-2119-remove-retired-outliner-ontology-runtime.md) | Remove the retired outliner ontology runtime | BASELINE-container | 2026-10-01 | accepted | complete | live | — | — | jjohare | agentbox | | [ADR-2120](ADR-2120-codex-daemon-package-volume.md) | Give Codex daemon packages executable persistent storage | BASELINE-container | 2026-10-01 | accepted | complete | live | — | — | jjohare | agentbox | | [ADR-2121](ADR-2121-factrail-implements-jev-compaction.md) | Implement Jev compaction with factrail — fact rails in Rust, baked at a pinned commit | GOVERNANCE-capabilities | 2026-10-02 | accepted | complete | staged | — | — | jjohare | agentbox | +| [ADR-2122](ADR-2122-role-service-accounts-run-secrets-and-the-identity-port.md) | Role service accounts, /run/secrets, and the identity port | SECURITY-profiles | 2026-10-03 | proposed | partial | inactive | — | — | jjohare | agentbox | diff --git a/docs/developer/ecosystem.md b/docs/developer/ecosystem.md index a93680abb..f3cd5cb59 100644 --- a/docs/developer/ecosystem.md +++ b/docs/developer/ecosystem.md @@ -47,7 +47,8 @@ the estate's **chain instance**, not the crates: the sealed `sidestr:dreamlab` d interim producer runner, mirror sync and faucet runner in `config/sidechain/`. The optional supervised faucet bakes the standalone AGPL `sidestr-agent` binary from a pinned upstream Git revision and lockfile (`lib/sidestr-agent.nix`), not into any permissive crate. -The interim producer remains the upstream JS engine; a future +The interim producer remains the upstream JS engine, baked read-only at the commits in +`config/sidechain/upstream-pins` (`lib/sidestr-upstream.nix`); a future `sidestr-node` takes them from crates.io and is AGPL-3.0 in effect. Testnet only; no real funds anywhere. diff --git a/docs/developer/role-isolation-runbook.md b/docs/developer/role-isolation-runbook.md new file mode 100644 index 000000000..741e4510b --- /dev/null +++ b/docs/developer/role-isolation-runbook.md @@ -0,0 +1,188 @@ +# Turning on role_isolation + +Owner runbook for `[security].role_isolation` (custody X-1 step 1, +[ADR-2122](../adr/ADR-2122-role-service-accounts-run-secrets-and-the-identity-port.md)). +What the flag does is in [SECURITY-profiles.md, "Role isolation"](../SECURITY-profiles.md#role-isolation--2026-10-03-custody-x-1-step-1-staged). +This page gives the order in which to do things, what to check, and how to back out. + +Written 2026-10-03 against `custody/integration` `d03defbea`. Every step that rebuilds, +restarts or reconfigures the container is the owner's. An agent may run the container half of +the rehearsal and commit receipts. It never rebuilds, restarts or flips the flag. + +No step here prints, copies or asks for a secret value. The rehearsal records names, paths, +modes, uids and counts only. + +## Before you start: what the result will be today + +At `d03defbea` the rehearsal is **expected to fail with the flag on**. The branch does not yet +contain the following: + +- **At-rest custody (W2 remainder).** devuser can still read `/var/lib/agentbox/secrets/*` and + the workspace treasury keys, so rehearsal check (a) fails on the at-rest copies. +- **The consumer cutover (W3b).** JunkieJarvis, dream-engine's forum posts, the live-mirror hook + and the gateway fail closed under the flag. +- **The producer's state move (W4).** Chain state is still on the workspace. + +Running the steps below now is still useful. The rebuild and the flag-off rehearsal prove that +the image is unchanged in effect. A short flag-on window with an immediate rollback records how +far the image gets. Do not leave the flag on until a rehearsal passes. + +## 1. Rebuild (once, flag still off) + +Accounts, both supervisor configs, the delivery plan, the `/run/secrets` mount, the identity +port and the baked sidestr upstream all ship in the image. The first image built from this +branch therefore needs a rebuild. After that, the flag only needs a restart. + +On the host, in the agentbox checkout, with the branch (or `main` after the merge) checked out: + +```sh +./agentbox.sh rebuild +``` + +Then check from the host shell. All of these are read-only: + +```sh +docker exec agentbox getent passwd ab-identity # ab-identity:x:960:960:... +docker exec agentbox findmnt -n -o FSTYPE /run/secrets # tmpfs +docker exec agentbox ls -l /etc/supervisord.roles.conf /etc/agentbox/role-secrets.tsv /etc/agentbox/role-accounts.json +docker exec agentbox stat -c '%U %a' /run/secrets # devuser 700 (flag off) +docker exec agentbox supervisorctl status +``` + +In `supervisorctl status`, two programs show `EXITED` by design while the flag is off: +`docker-read-proxy` and `serve-identity`. Every other program should be in the state it was in +before the rebuild. + +If `[sidechain]` is enabled, the producer now runs the baked upstream in both modes +(`config/sidechain/run-producer.sh`). The log must not say `stale bake` or `no baked upstream`. + +## 2. Baseline rehearsal (flag off) + +Both halves report `STAGED` (exit 2) with the flag off. Every row is "not applicable: flag +off", but the receipt still records what was observed. This is a dry run of the procedure. + +**Container half**, as devuser inside the container. Run it either from an agent terminal in +the agentbox checkout: + +```sh +bash scripts/activation/role-isolation-rehearsal.sh +``` + +or from the host: + +```sh +docker exec -u 1000 agentbox bash /home/devuser/workspace/project/agentbox/scripts/activation/role-isolation-rehearsal.sh +``` + +**Host half**, as root on the host, from the host's agentbox checkout: + +```sh +sudo scripts/activation/role-isolation-rehearsal.host.sh +``` + +The host half refuses to run inside a container. It also refuses when `scripts/activation` +differs from `HEAD`: that checkout is writable by devuser, so review +`git diff HEAD -- scripts/activation` before passing `--allow-dirty`. It reports any host +account in the 960–979 range as a failure, and it records the host Docker socket's mode. + +## 3. Host prerequisite: the Docker socket + +Under the flag the entrypoint stops widening `/var/run/docker.sock`. If the host's socket is +world-writable anyway, the boot records `degraded:docker-socket`, and the rehearsal counts that +as a failure. The fix is on the host (owner question Q2): + +```sh +sudo chmod 0660 /var/run/docker.sock +``` + +## 4. Turn the flag on + +Edit `agentbox.toml` in the host checkout. It is bind-mounted read-only at `/etc/agentbox.toml`. + +```toml +[security] +role_isolation = true +``` + +Restart the container so that the entrypoint runs again. The `/run/secrets` tmpfs starts empty +on every start. + +```sh +docker restart agentbox +docker exec agentbox cat /run/secrets/role-isolation.state # ok:/degraded: per area, no values +docker logs agentbox 2>&1 | grep 'ROLE-ISOLATION-' # any line here is a finding +``` + +From this point the container's devuser shells have a read-only Docker CLI: +`DOCKER_HOST=unix:///run/docker-ro.sock` allows `ps`, `logs` and `inspect`, while `exec` and +`run` get 403. Run anything that needs `docker exec` from the host shell. + +## 5. Rehearse (flag on) + +Run both halves as in step 2. With the flag on, the verdict is `PASS` (exit 0) only if every +row passes. + +```sh +# container half; --wait lets check (d) poll for a new block for up to 2 x the +# producer interval (20 min at the default 600 s) +bash scripts/activation/role-isolation-rehearsal.sh --wait +# host half (host shell, root) +sudo scripts/activation/role-isolation-rehearsal.host.sh +``` + +| Check | Proves | Half | +|---|---|---| +| (a) | each role secret is readable only by its role; at-rest copies are root `0400`; `/run/secrets` is a root mount; devuser can neither rename the port's directory nor plant one beside it | both | +| (b) | each role program runs as its uid; its `/proc//environ` is closed to devuser | both | +| (c) | signing works through the identity port, and refusals are refused and receipted | container | +| (d) | each enabled chain's producer runs as its role and makes a block | container | +| (e) | no classified variable name is ambient in any process, config, `identity.env` or log | both | +| (f) | devuser is not in group 0, cannot drive the socket, cannot sudo | container | + +## 6. Receipts + +- **Container half:** `docs/estate-closeout/x1-rehearsal-.json`. +- **Host half:** `docs/estate-closeout/x1-rehearsal-host-.json`, in the checkout that ran + each half. + +Both follow `docs/estate-closeout/schema/x1-rehearsal.schema.json`. Commit both, PASS or FAIL. +ADR-2122's `activation_status` moves only on a pair of PASS receipts from a rebuilt image with +the flag on. A message from a peer agent is not approval. + +## 7. Roll back + +Set `role_isolation = false` in the host checkout and run `docker restart agentbox`. No rebuild +is needed and no data moves: + +- The flag-off boot chowns `/run/secrets` back to devuser. +- It writes the devuser `nostr.key` as before. +- It runs `/etc/supervisord.conf`. + +Nothing on this branch migrates the at-rest volumes, so there is nothing to revert. Roll back +straight after a failing flag-on rehearsal. + +Not reversible by the flag (W0, image-level): devuser is no longer in group `root`, and root's +boot `PATH` is store-only. Only a rebuild of an older image undoes those. + +## Separate: the browser sidecar's key (Podkey) + +This part does not depend on the flag. The sidecar's key, K_browser (G-5), lives in Podkey +inside browsercontainer, not in this container. The full procedure is in +[`browsercontainer/README.md`, "Podkey and the persistent profile"](../../browsercontainer/README.md#podkey-and-the-persistent-profile-2026-10-03-custody-isolation-w9). +In order: + +1. Rebuild the sidecar with `./agentbox.sh browsercontainer rebuild`. This needs `gh` auth for + the pinned artefact the first time. +2. Run `./agentbox.sh browsercontainer podkey status`. Expect `"state": "none"` and extension + id `cakgjkgiodcdhecnnfhmcfphjkknfjkd`. +3. Over VNC, open Podkey's popup and choose **Generate new key**, using a passphrase of at least + 8 characters. Do not import the house key. +4. Back the key up at mint time: **Export key**, then seal it with your age recipient on your + own machine. It never enters a repository or an agent's view. +5. Run `./agentbox.sh browsercontainer podkey pubkey` and give the agent the 64-hex **public** + key. The agent adds it to `config/custody/g5-key-split.json` (G-5 step 2; see + [the G-5 handoff](../estate-closeout/g5-key-split-handoff.md)). +6. After every sidecar restart, run `./agentbox.sh browsercontainer podkey unlock`. It prompts + for the passphrase without echo. The vault survives on the `browsercontainer-profile` + volume. Never run `down -v` on the sidecar, because that deletes the volume and the sealed + key with it. diff --git a/docs/estate-closeout/README.md b/docs/estate-closeout/README.md new file mode 100644 index 000000000..b3e56e5e2 --- /dev/null +++ b/docs/estate-closeout/README.md @@ -0,0 +1 @@ +- `x1-rehearsal-20261003T090708Z.json` is superseded and is kept unedited as a record: it ran a pre-integration role table that put `ab-faucet-dreamlab` at uid 965, the host Docker group's gid, which `config/role-accounts.json` now reserves (the faucet is 966). Use a later `x1-rehearsal-.json`. diff --git a/docs/estate-closeout/g5-key-split-handoff.md b/docs/estate-closeout/g5-key-split-handoff.md new file mode 100644 index 000000000..4c55cbce1 --- /dev/null +++ b/docs/estate-closeout/g5-key-split-handoff.md @@ -0,0 +1,117 @@ +# G-5 key split: owner handoff + +Date: 2026-10-03 · Branch: `custody/w8-key-split` (not merged) · TODO row G-5 · Design: custody design §11 + +## What this is about + +One Nostr key, `11ed6422…663c`, does several jobs: it is the container's identity, the forum +house admin, the identity VisionClaw signs with, and (by its label) the browser sidecar's login. +Its private half is in agentbox `.env`, in VisionClaw `.env`, and reportedly in the browser +sidecar. Whoever has any one copy can do every one of those jobs. The split keeps `11ed6422…` +as the container identity only. Each other job gets its own new key, created where it will be +used, and then the outside copies of the house key are deleted. + +## Already done on this branch (nothing changes behaviour) + +1. **Q15: VisionClaw signs with the house key `11ed6422…663c`.** VisionClaw sets only + `VISIONCLAW_NOSTR_PRIVKEY`; `ACSP_PANEL_NOSTR_PRIVKEY` is unset (variable names checked, + never values). That one variable feeds three signers: governance (via the fallback in + `elevation_actor.rs:199-200`, `decision_elevation_actor.rs:183-184`, `app_state.rs:1362-1363`), + the NostrBridge (`nostr_bridge.rs:37`) and the bead publisher (`nostr_bead_publisher.rs:40`). + On the dreamlab relay, VisionClaw's 31402s are signed by `11ed6422…`. `b4165401…2f7a` signs + only 31403 decisions: it is your NIP-07 operator key, and the `agentbox.toml` comments are + corrected to say so. +2. **The phone mirror key is pinned** by seven known-answer tests + (`services/nostr-pod-bridge/tests/mirror_key.rs`). The phone key does not change. +3. **The zone migrator is already unused.** `nostr-bbs-zone-migrate` (forum repo) takes its key + per run and holds none. Retiring it means withdrawing `11ed6422…` as forum admin (step 6). + Keep the tool, because re-sealing history (Q16) would need it. +4. **Verifier tests are ready for your public keys.** `config/custody/g5-key-split.json` is the + one place the new **public** keys go. `tests/sovereign/g5-key-split.node-test.js` then checks + each one in four ways: it is a real key; it is new; it is in the agentbox allowlist next to + the house key; and the running login proxy lets it in and turns a stranger away. Until you + supply a key its two checks are skipped, and a made-up key exercises the same path. + +## Decisions (2026-10-03) + +- **D1, waiting for you:** your age recipient (`age1…`). +- **D2:** you create K_admin yourself in your NIP-07 extension. No container ever holds it. +- **D3:** each key is created where it will live, never in agentbox. This repo only ever sees + public keys. + +Because of D3, the agent never sees K_broker or K_browser, so it cannot run the backup on them. +**You back them up**, with the age recipient from D1, at the moment you create them. You hold +the age identity, so you are also the one who can test the restore. The steps are below. + +## The new keys + +| Key | Job | Created by, where | Public key | +|---|---|---|---| +| K_admin | forum house admin | you, in your NIP-07 extension | not yet created | +| K_broker | VisionClaw signer (governance, NostrBridge, beads) | you, inside `visionclaw_container`, from tab 6 | not yet created | +| K_browser | browser sidecar login at the :8444 proxy | you, in a NIP-07 signer inside browsercontainer | not yet created | + +## Creating K_broker inside VisionClaw (tab 6, VisionClaw checkout on the host) + +VisionClaw has no key-minting command of its own, and its docs suggest `openssl rand -hex 32`. +Use the library generator that ships in VisionClaw's own container instead (`nostr-tools`, built +on `@noble/curves`). The secret goes straight into a 0600 file and is never shown on screen. + +```sh +umask 077 +docker exec visionclaw_container node -e ' + const t = require("/app/client/node_modules/nostr-tools"); + const sk = t.generateSecretKey(); + process.stderr.write("K_broker pubkey: " + t.getPublicKey(sk) + "\n"); + process.stdout.write(Buffer.from(sk).toString("hex"));' > k_broker.hex +age -r age1…YOUR-RECIPIENT -o k_broker.hex.age k_broker.hex # backup (D1) +age -d -i YOUR-IDENTITY k_broker.hex.age | cmp - k_broker.hex && echo restore-ok +``` + +Send the printed **pubkey** only. Hold off on the `.env` swap until step 4 below. + +## Creating K_browser, and the "Podkey vault" copy + +- **What is actually in the sidecar today (seen 2026-10-03; vault contents not read):** + browsercontainer's only browser profile is `/tmp/chrome-profile`. It is not on a volume, so it + is wiped whenever the container is recreated. It has three extensions: Google Docs Offline, + Chrome Web Store Payments and **Proton Pass**. There is no Podkey extension. The + `agentbox.toml` label "browsercontainer Podkey vault" is the only record of a house-key copy + there. +- **Please confirm where the house key really is.** It may be a Podkey install elsewhere, or an + entry in a **Proton Pass vault**. If it is in Proton Pass, that copy is also synced to + Proton's cloud and to every device signed into that account. Deleting it is then a Proton + account action, not a container clean-up. +- **To create K_browser:** + 1. Install a NIP-07 signer in the sidecar's Chromium. Podkey is the estate's choice. + 2. Create a new key in that signer (do not import one). + 3. Back the key up with `age` as for K_broker, if the signer lets you export it. A + passkey-derived Podkey key can be re-derived from its passkey instead. + 4. Send the pubkey. +- **Keep the profile.** Because it lives in `/tmp`, a recreate loses the signer and its key. + Either put `/tmp/chrome-profile` on a named volume before you create K_browser, or accept + creating a new key (and a new allowlist change) after every recreate. + +## What remains, in order (no flag day) + +| Step | Who | What | +|---|---|---| +| 1 | you | Create K_admin, K_broker and K_browser as above, back up the two that can be exported, and send the three **public** keys. | +| 2 | agent | Add each pubkey to `config/custody/g5-key-split.json`. Put K_browser in `[interaction_plane.proxy].allowed_pubkeys` next to `11ed6422…`. The tests must then pass with no skips. K_broker and K_admin have no verifier in agentbox: theirs are the forum relay whitelist and the forum admin config, in the forum deploy. | +| 3 | **you: forum deploy, then agentbox restart** | Add K_broker to the forum relay whitelist and K_admin to the forum admins (forum deploy). The proxy allowlist is applied at boot, so agentbox needs a restart rather than an image rebuild. Both old and new keys must then be accepted. | +| 4 | you | Switch one signer at a time. **VisionClaw:** `awk -v f=k_broker.hex 'BEGIN{getline k < f} /^VISIONCLAW_NOSTR_PRIVKEY=/{print "VISIONCLAW_NOSTR_PRIVKEY=" k; next} {print}' .env > .env.new && chmod --reference=.env .env.new && mv .env.new .env && shred -u k_broker.hex`. awk reads the secret from the file, so it never appears in a command line or in `ps`. Check that `grep -c '^VISIONCLAW_NOSTR_PRIVKEY=' .env` prints 1, then recreate VisionClaw. This one edit also removes VisionClaw's copy of the house key. No rollback copy is needed, because the house key stays in agentbox `.env`. **Sidecar:** log in with K_browser. **Forum:** act as admin with K_admin. Then check the relay: new 31402s must be signed by K_broker and accepted. | +| 5 | agent, then **you: forum deploy and agentbox restart** | Add the withdrawn roles to `house_key_withdrawn_from`, and remove `11ed6422…` from the proxy allowlist and from the forum admins. The tests then require the old key to be **refused**. The relay keeps `11ed6422…`, because it stays the container identity. | +| 6 | **you** | Delete the remaining outside copy: the sidecar's (Podkey or Proton Pass, as you confirmed above). VisionClaw's copy already went in step 4. The private half then exists only in agentbox `.env`, its runtime copy and its existing backups. | + +VisionClaw's NostrBridge and bead publisher will sign as K_broker after step 4. Anything that +checks bead provenance against `11ed6422…` needs K_broker added too. No such checker exists in +agentbox. + +## The part that cannot be undone + +`11ed6422…` was the forum house admin and still holds the zone2/zone3 **epoch-1 zone secrets**. +Whoever holds that key can read the **sealed forum history** in zones 2-4, and taking it off the +admin list does not change that. New epochs under K_admin protect only posts written after the +change. The only fix for old posts is to re-seal them under new zone keys. That is Q16, together +with the plaintext pre-purge forum backups on `/mnt/dell`. Until Q16 is decided, treat agentbox +`.env` as able to read every sealed forum post written so far. diff --git a/docs/estate-closeout/schema/x1-rehearsal.schema.json b/docs/estate-closeout/schema/x1-rehearsal.schema.json new file mode 100644 index 000000000..c450f1187 --- /dev/null +++ b/docs/estate-closeout/schema/x1-rehearsal.schema.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/DreamLab-AI/agentbox/docs/estate-closeout/schema/x1-rehearsal.schema.json", + "title": "X-1 role-isolation rehearsal receipt", + "description": "Written by scripts/activation/role-isolation-rehearsal.sh (half = container, run as devuser) and scripts/activation/role-isolation-rehearsal.host.sh (half = host, run as root on the host). Decides whether [security].role_isolation may stay on (custody design §4). A receipt holds names, paths, modes, uids, gids, pids, counts and public keys only: never a secret value, hash or fingerprint. Evidence keys that name a value-bearing field are refused by propertyNames.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "half", "verdict", "exit_code", "flag", "image", "commit", "started_at", "finished_at", "operator", "uid_table", "checks", "summary", "legacy_copies", "degraded", "not_covered"], + "properties": { + "schema": { "const": "agentbox/x1-rehearsal@1" }, + "half": { "enum": ["container", "host"] }, + "verdict": { "enum": ["PASS", "STAGED", "FAIL"] }, + "exit_code": { "enum": [0, 1, 2] }, + "flag": { + "type": "object", + "additionalProperties": false, + "required": ["key", "value", "source"], + "properties": { + "key": { "const": "[security].role_isolation" }, + "value": { "type": "boolean" }, + "source": { "enum": ["manifest", "absent-default-false", "container-receipt"] }, + "manifest": { "type": "string" } + } + }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["build_id"], + "properties": { + "build_id": { "type": "string", "minLength": 1, "description": "Image id (sha256:...) or 'unknown'." }, + "version": { "type": "string" }, + "container": { "type": "string" } + } + }, + "commit": { "type": "string", "minLength": 1 }, + "rehearsal_dirty": { "type": "boolean", "description": "scripts/activation differs from HEAD in the checkout that ran the rehearsal." }, + "started_at": { "$ref": "#/$defs/utc" }, + "finished_at": { "$ref": "#/$defs/utc" }, + "operator": { + "type": "object", + "additionalProperties": false, + "required": ["user", "uid"], + "properties": { "user": { "type": "string" }, "uid": { "type": "integer" } } + }, + "supervisor_config": { "type": "string" }, + "uid_table": { + "type": "object", + "additionalProperties": false, + "required": ["source", "roles"], + "properties": { + "source": { "type": "string" }, + "roles": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "uid", "gid", "programs", "files"], + "properties": { + "role": { "type": "string", "pattern": "^ab-[a-z0-9-]+$" }, + "uid": { "type": "integer", "minimum": 960, "maximum": 979 }, + "gid": { "type": "integer", "minimum": 960, "maximum": 979 }, + "programs": { "type": "array", "items": { "type": "string" } }, + "files": { "type": "array", "items": { "type": "string", "pattern": "^/run/secrets/" } }, + "classified_env": { "type": "array", "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" } }, + "deferred": { "type": "string" } + } + } + }, + "host_accounts": { + "type": "array", + "description": "Host half: getent passwd/group entries found in 960-979 (names and numbers only).", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["db", "id", "name"], + "properties": { "db": { "enum": ["passwd", "group"] }, "id": { "type": "integer" }, "name": { "type": "string" } } + } + } + } + }, + "checks": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/check" } }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["pass", "fail", "not_applicable", "would_fail", "failed_checks"], + "properties": { + "pass": { "type": "integer", "minimum": 0 }, + "fail": { "type": "integer", "minimum": 0 }, + "not_applicable": { "type": "integer", "minimum": 0 }, + "recorded": { "type": "integer", "minimum": 0 }, + "would_fail": { "type": "integer", "minimum": 0 }, + "failed_checks": { "type": "array", "items": { "$ref": "#/$defs/check_id" }, "uniqueItems": true } + } + }, + "legacy_copies": { "type": "array", "items": { "type": "string" }, "description": "Paths of legacy key copies kept until a manual purge (owner Q5). Paths only." }, + "degraded": { "type": "array", "items": { "type": "string" } }, + "not_covered": { "type": "array", "items": { "type": "string" } } + }, + "allOf": [ + { "if": { "properties": { "verdict": { "const": "PASS" } } }, "then": { "properties": { "exit_code": { "const": 0 }, "flag": { "properties": { "value": { "const": true } } }, "summary": { "properties": { "fail": { "const": 0 } } } } } }, + { "if": { "properties": { "verdict": { "const": "STAGED" } } }, "then": { "properties": { "exit_code": { "const": 2 }, "flag": { "properties": { "value": { "const": false } } }, "checks": { "items": { "properties": { "status": { "enum": ["not_applicable", "recorded"] } } } } } } }, + { "if": { "properties": { "verdict": { "const": "FAIL" } } }, "then": { "properties": { "exit_code": { "const": 1 } } } } + ], + "$defs": { + "utc": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" }, + "check_id": { "type": "string", "pattern": "^(a|b|c|d|e|f|h-[a-z0-9-]+)$" }, + "check": { + "type": "object", + "additionalProperties": false, + "required": ["check", "target", "expected", "observed", "status", "evidence"], + "properties": { + "check": { "$ref": "#/$defs/check_id" }, + "target": { "type": "string", "minLength": 1 }, + "expected": { "type": "string" }, + "observed": { "type": "string", "maxLength": 400 }, + "status": { "enum": ["pass", "fail", "not_applicable", "recorded"], "description": "recorded: reported for the owner, not a gate (e.g. the host socket mode, owner Q2)." }, + "reason": { "type": "string" }, + "would_pass": { "type": "boolean", "description": "Flag off only: whether the row would have passed with the flag on." }, + "evidence": { "$ref": "#/$defs/evidence" } + }, + "allOf": [ + { "if": { "properties": { "status": { "const": "not_applicable" } } }, "then": { "required": ["reason", "would_pass"] } } + ] + }, + "evidence": { + "type": "object", + "propertyNames": { "pattern": "^[a-z][a-z0-9_]*$", "not": { "pattern": "(value|secret|nsec|privkey|private|password|token|bearer|content|hash|fingerprint)" } }, + "additionalProperties": { "$ref": "#/$defs/evidence_item" } + }, + "evidence_item": { + "anyOf": [ + { "type": ["integer", "boolean", "null"] }, + { "type": "string", "maxLength": 300 }, + { "type": "array", "maxItems": 500, "items": { "$ref": "#/$defs/evidence_item" } }, + { "$ref": "#/$defs/evidence" } + ] + } + } +} diff --git a/docs/estate-closeout/x1-rehearsal-20261003T090708Z.json b/docs/estate-closeout/x1-rehearsal-20261003T090708Z.json new file mode 100644 index 000000000..3cadf2855 --- /dev/null +++ b/docs/estate-closeout/x1-rehearsal-20261003T090708Z.json @@ -0,0 +1,805 @@ +{ + "schema": "agentbox/x1-rehearsal@1", + "half": "container", + "verdict": "STAGED", + "exit_code": 2, + "flag": { + "key": "[security].role_isolation", + "value": false, + "source": "absent-default-false", + "manifest": "/etc/agentbox.toml" + }, + "image": { + "build_id": "sha256:ef292f552fd387d351f9c90aa9a98f955f9352846a0052e9690b8ed49a378203", + "version": "2.0.0" + }, + "commit": "3cd5614738f6e3d85d25961b832b1074f307c348", + "rehearsal_dirty": false, + "started_at": "2026-10-03T09:06:53Z", + "finished_at": "2026-10-03T09:07:08Z", + "operator": { + "user": "devuser", + "uid": 1000 + }, + "supervisor_config": "/etc/supervisord.conf", + "uid_table": { + "source": "derived from /etc/agentbox.toml (design §2.2 numbering)", + "roles": [ + { + "role": "ab-identity", + "uid": 960, + "gid": 960, + "programs": [ + "nostr-relay" + ], + "files": [ + "/run/secrets/ab-identity/nostr.key" + ], + "classified_env": [ + "AGENTBOX_PRIVKEY_HEX", + "AGENTBOX_NSEC", + "AGENTBOX_BRIDGE_SK", + "AGENTBOX_AGENT_PRIVKEY_HEX", + "JUNKIEJARVIS_PRIVKEY_HEX", + "CONCIERGE_PRIVKEY_HEX" + ] + }, + { + "role": "ab-gateway", + "uid": 961, + "gid": 961, + "programs": [ + "nostr-gateway" + ], + "files": [], + "classified_env": [] + }, + { + "role": "ab-ingress", + "uid": 962, + "gid": 962, + "programs": [ + "nip98-proxy" + ], + "files": [], + "classified_env": [ + "NIP98_PROXY_ALLOW_BEARER", + "NIP98_PROXY_SESSION_SECRET" + ] + }, + { + "role": "ab-spend", + "uid": 963, + "gid": 963, + "programs": [], + "files": [], + "classified_env": [], + "deferred": "Q6: pay402 and Hitch stay devuser-class in step 1" + }, + { + "role": "ab-sidestr-dreamlab", + "uid": 964, + "gid": 964, + "programs": [ + "sidestr-producer" + ], + "files": [ + "/run/secrets/ab-sidestr-dreamlab/signer.key", + "/run/secrets/ab-sidestr-dreamlab/parent.cred" + ], + "classified_env": [] + }, + { + "role": "ab-faucet-dreamlab", + "uid": 965, + "gid": 965, + "programs": [ + "sidestr-faucet" + ], + "files": [ + "/run/secrets/ab-faucet-dreamlab/treasury.key" + ], + "classified_env": [] + } + ] + }, + "checks": [ + { + "check": "a", + "target": "/run/secrets", + "expected": "root-owned 0711 mount point", + "observed": "uid 1000 mode 700, same filesystem as /run (renamable by /run's owner)", + "evidence": { + "path": "/run/secrets", + "uid": "1000", + "mode": "700", + "mount_point": false + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-identity", + "expected": "dir 0500 owned by ab-identity (960)", + "observed": "absent", + "evidence": { + "path": "/run/secrets/ab-identity", + "uid": "", + "mode": "" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-identity/nostr.key", + "expected": "devuser open() -> EACCES", + "observed": "ENOENT", + "evidence": { + "path": "/run/secrets/ab-identity/nostr.key", + "devuser_open": "ENOENT" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-gateway", + "expected": "dir 0500 owned by ab-gateway (961)", + "observed": "absent", + "evidence": { + "path": "/run/secrets/ab-gateway", + "uid": "", + "mode": "" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-ingress", + "expected": "dir 0500 owned by ab-ingress (962)", + "observed": "absent", + "evidence": { + "path": "/run/secrets/ab-ingress", + "uid": "", + "mode": "" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-sidestr-dreamlab", + "expected": "dir 0500 owned by ab-sidestr-dreamlab (964)", + "observed": "absent", + "evidence": { + "path": "/run/secrets/ab-sidestr-dreamlab", + "uid": "", + "mode": "" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-sidestr-dreamlab/signer.key", + "expected": "devuser open() -> EACCES", + "observed": "ENOENT", + "evidence": { + "path": "/run/secrets/ab-sidestr-dreamlab/signer.key", + "devuser_open": "ENOENT" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-sidestr-dreamlab/parent.cred", + "expected": "devuser open() -> EACCES", + "observed": "ENOENT", + "evidence": { + "path": "/run/secrets/ab-sidestr-dreamlab/parent.cred", + "devuser_open": "ENOENT" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-faucet-dreamlab", + "expected": "dir 0500 owned by ab-faucet-dreamlab (965)", + "observed": "absent", + "evidence": { + "path": "/run/secrets/ab-faucet-dreamlab", + "uid": "", + "mode": "" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/ab-faucet-dreamlab/treasury.key", + "expected": "devuser open() -> EACCES", + "observed": "ENOENT", + "evidence": { + "path": "/run/secrets/ab-faucet-dreamlab/treasury.key", + "devuser_open": "ENOENT" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/run/secrets/nostr.key", + "expected": "devuser open() -> EACCES or ENOENT (at rest)", + "observed": "ok", + "evidence": { + "path": "/run/secrets/nostr.key", + "devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/var/lib/agentbox/identities/agentbox-core.json", + "expected": "devuser open() -> EACCES or ENOENT (at rest)", + "observed": "ok", + "evidence": { + "path": "/var/lib/agentbox/identities/agentbox-core.json", + "devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/var/lib/agentbox/secrets/sidestr-dreamlab.key", + "expected": "devuser open() -> EACCES or ENOENT (at rest)", + "observed": "ok", + "evidence": { + "path": "/var/lib/agentbox/secrets/sidestr-dreamlab.key", + "devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/var/lib/agentbox/secrets/sidestr-faucet-dreamlab.key", + "expected": "devuser open() -> EACCES or ENOENT (at rest)", + "observed": "ENOENT", + "evidence": { + "path": "/var/lib/agentbox/secrets/sidestr-faucet-dreamlab.key", + "devuser_open": "ENOENT" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": true + }, + { + "check": "a", + "target": "/var/lib/agentbox/secrets/sidestr-tbtc4.cookie", + "expected": "devuser open() -> EACCES or ENOENT (at rest)", + "observed": "ok", + "evidence": { + "path": "/var/lib/agentbox/secrets/sidestr-tbtc4.cookie", + "devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/var/lib/agentbox/identities/", + "expected": "devuser opendir() -> EACCES (at rest)", + "observed": "ok", + "evidence": { + "path": "/var/lib/agentbox/identities", + "devuser_opendir": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "a", + "target": "/var/lib/agentbox/secrets/", + "expected": "devuser opendir() -> EACCES (at rest)", + "observed": "ok", + "evidence": { + "path": "/var/lib/agentbox/secrets", + "devuser_opendir": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "b", + "target": "nostr-relay", + "expected": "RUNNING as ab-identity (960), environ unreadable", + "observed": "pid 379 uid 1000/1000, environ ok", + "evidence": { + "program": "nostr-relay", + "pid": 379, + "uid": "1000", + "environ_devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "b", + "target": "nostr-gateway", + "expected": "RUNNING as ab-gateway (961), environ unreadable", + "observed": "pid 773 uid 1000/1000, environ ok", + "evidence": { + "program": "nostr-gateway", + "pid": 773, + "uid": "1000", + "environ_devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "b", + "target": "nip98-proxy", + "expected": "RUNNING as ab-ingress (962), environ unreadable", + "observed": "pid 431 uid 1000/1000, environ ok", + "evidence": { + "program": "nip98-proxy", + "pid": 431, + "uid": "1000", + "environ_devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "b", + "target": "sidestr-producer", + "expected": "RUNNING as ab-sidestr-dreamlab (964)", + "observed": "STOPPED", + "evidence": { + "program": "sidestr-producer", + "state": "STOPPED" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "b", + "target": "sidestr-faucet", + "expected": "RUNNING as ab-faucet-dreamlab (965), environ unreadable", + "observed": "pid 882 uid 1000/1000, environ ok", + "evidence": { + "program": "sidestr-faucet", + "pid": 882, + "uid": "1000", + "environ_devuser_open": "ok" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "/run/secrets/port/identity.sock", + "expected": "identity port socket present", + "observed": "absent", + "evidence": { + "path": "/run/secrets/port/identity.sock" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "pubkey", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "pods-nip98", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "forum-event", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "relay-nip42", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "refuse-dm_unwrap", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "refuse-foreign-url", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "refuse-generic-sign", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "c", + "target": "receipts", + "expected": "made through the port", + "observed": "not attempted: port socket absent", + "evidence": {}, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "d", + "target": "sidestr-producer", + "expected": "producer runs as ab-sidestr-dreamlab (964)", + "observed": "not RUNNING under supervisor", + "evidence": { + "program": "sidestr-producer", + "pid": "", + "uid": "" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "d", + "target": "sidestr:dreamlab tip", + "expected": "a block younger than 1200s on :3450", + "observed": "height 1019, newest block 301s old", + "evidence": { + "port": 3450, + "height": 1019, + "block_age_s": 301, + "interval_s": 600 + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": true + }, + { + "check": "e", + "target": "argv", + "expected": "no classified name or secret flag on any argv (159 processes)", + "observed": "0 hits", + "evidence": { + "processes": 159, + "hits_total": 0, + "by_name": [], + "sample": [] + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": true + }, + { + "check": "e", + "target": "devuser-environ", + "expected": "no classified name in any devuser process environ (157 processes)", + "observed": "942 hits, 0 unreadable", + "evidence": { + "devuser_processes": 157, + "hits_total": 942, + "by_name": [ + { + "name": "AGENTBOX_NSEC", + "belongs_to": "ab-identity", + "processes": 157 + }, + { + "name": "AGENTBOX_PRIVKEY_HEX", + "belongs_to": "ab-identity", + "processes": 157 + }, + { + "name": "JUNKIEJARVIS_PRIVKEY_HEX", + "belongs_to": "ab-identity", + "processes": 157 + }, + { + "name": "NIP98_PROXY_ALLOW_BEARER", + "belongs_to": "ab-ingress", + "processes": 157 + }, + { + "name": "NIP98_PROXY_SESSION_SECRET", + "belongs_to": "ab-ingress", + "processes": 157 + }, + { + "name": "TAILSCALE_AUTHKEY", + "belongs_to": "root", + "processes": 157 + } + ], + "sample": [ + { + "pid": 10369, + "uid": 1000, + "name": "AGENTBOX_NSEC" + }, + { + "pid": 10369, + "uid": 1000, + "name": "NIP98_PROXY_SESSION_SECRET" + }, + { + "pid": 10369, + "uid": 1000, + "name": "AGENTBOX_PRIVKEY_HEX" + }, + { + "pid": 10369, + "uid": 1000, + "name": "NIP98_PROXY_ALLOW_BEARER" + }, + { + "pid": 10369, + "uid": 1000, + "name": "TAILSCALE_AUTHKEY" + }, + { + "pid": 10369, + "uid": 1000, + "name": "JUNKIEJARVIS_PRIVKEY_HEX" + }, + { + "pid": 10566, + "uid": 1000, + "name": "AGENTBOX_NSEC" + }, + { + "pid": 10566, + "uid": 1000, + "name": "NIP98_PROXY_SESSION_SECRET" + }, + { + "pid": 10566, + "uid": 1000, + "name": "AGENTBOX_PRIVKEY_HEX" + }, + { + "pid": 10566, + "uid": 1000, + "name": "NIP98_PROXY_ALLOW_BEARER" + }, + { + "pid": 10566, + "uid": 1000, + "name": "TAILSCALE_AUTHKEY" + }, + { + "pid": 10566, + "uid": 1000, + "name": "JUNKIEJARVIS_PRIVKEY_HEX" + }, + { + "pid": 10567, + "uid": 1000, + "name": "AGENTBOX_NSEC" + }, + { + "pid": 10567, + "uid": 1000, + "name": "NIP98_PROXY_SESSION_SECRET" + }, + { + "pid": 10567, + "uid": 1000, + "name": "AGENTBOX_PRIVKEY_HEX" + }, + { + "pid": 10567, + "uid": 1000, + "name": "NIP98_PROXY_ALLOW_BEARER" + }, + { + "pid": 10567, + "uid": 1000, + "name": "TAILSCALE_AUTHKEY" + }, + { + "pid": 10567, + "uid": 1000, + "name": "JUNKIEJARVIS_PRIVKEY_HEX" + }, + { + "pid": 10568, + "uid": 1000, + "name": "AGENTBOX_NSEC" + }, + { + "pid": 10568, + "uid": 1000, + "name": "NIP98_PROXY_SESSION_SECRET" + } + ] + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "e", + "target": "supervisor-text", + "expected": "no classified name on an environment= line", + "observed": "0 lines", + "evidence": { + "hits": [] + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": true + }, + { + "check": "e", + "target": "/run/agentbox/identity.env", + "expected": "no classified name", + "observed": "unreadable to devuser (root half reads it)", + "evidence": { + "path": "/run/agentbox/identity.env", + "devuser_open": "EACCES" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": true + }, + { + "check": "e", + "target": "/var/log", + "expected": "no NAME=value line for a classified name", + "observed": "0 lines", + "evidence": { + "hits": [] + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": true + }, + { + "check": "f", + "target": "groups", + "expected": "devuser not in group 0", + "observed": "in group 0 (groups: 1000 0 998)", + "evidence": { + "groups": "1000 0 998" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "f", + "target": "docker", + "expected": "devuser cannot drive the docker daemon", + "observed": "docker version answered (server 29.8.1)", + "evidence": { + "command": "docker version" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + }, + { + "check": "f", + "target": "sudo", + "expected": "devuser cannot sudo", + "observed": "refused", + "evidence": { + "command": "sudo -n true" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": true + }, + { + "check": "f", + "target": "docker.sock-gid", + "expected": "no role gid equals the socket's group", + "observed": "socket gid 965, collides with a role gid", + "evidence": { + "path": "/var/run/docker.sock", + "gid": 965, + "mode": "666" + }, + "status": "not_applicable", + "reason": "flag off", + "would_pass": false + } + ], + "summary": { + "pass": 0, + "fail": 0, + "not_applicable": 42, + "would_fail": 35, + "failed_checks": [ + "a", + "b", + "c", + "d", + "e", + "f" + ] + }, + "legacy_copies": [ + "/home/devuser/workspace/.agentbox/zone-keys.json", + "/home/devuser/workspace/sidestr/agents/treasury.key" + ], + "degraded": [], + "not_covered": [ + "planting a probe on root PATH and starting [program:bootstrap] (W0 runtime-contract test; the rehearsal never starts or restarts programs)", + "renaming /run/secrets (destructive on a failing image; the mount-point and owner test in (a) stands in for it)", + "a real pod write with sign_requests=true (the pods header is verified by NostrBridge.verifyNip98, not sent)", + "idempotence and rollback across reboots (owner-run; needs two boots)", + "role-uid reads, other uids environ and host socket mode and host uid collisions (role-isolation-rehearsal.host.sh)" + ] +} diff --git a/docs/user/configuration.md b/docs/user/configuration.md index ed21b1b40..6a6be81b9 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -802,6 +802,38 @@ Run `preflight` before `up` whenever you change `agentbox.toml`, the override fi Hardening baseline is applied unconditionally. Feature-specific privilege expansions are manifest-declared. +### `[security].role_isolation` — role service accounts (ADR-2122) + +```toml +[security] +role_isolation = false # boot-class; keep off until the rehearsal passes +``` + +On, the boot does the following: + +- It keeps `/run/secrets` root-owned and copies each role's secrets into + `/run/secrets//` (directory `0500`, files `0400`, owned by the role). +- It writes the 11 ROLE-class variables to files and unsets them from PID 1. The + classes are in `config/custody/env-classes.json`; `TAILSCALE_AUTHKEY` goes to + root only. +- It renders `identity.env` with public values only. +- It execs `/etc/supervisord.roles.conf`. In that config `nostr-relay` and + `serve-identity` (the identity port) run as `ab-identity`, and `nostr-gateway`, + `nip98-proxy` and the sidestr producers and faucets run as their own roles. +- devuser's Docker CLI moves to the GET-only `/run/docker-ro.sock`, so + `docker exec` from inside the container stops working. + +The roles, uids and secret mapping live in `config/role-accounts.json`. Off, the +boot is today's. Problems are logged with `ROLE-ISOLATION-` markers and recorded +in `/run/secrets/role-isolation.state`. None of them stops the boot. + +The first image that carries the flag needs `./agentbox.sh rebuild`. After that +the flag applies on a restart. Several pieces are not on the branch yet (at-rest +custody, the W3b consumer cutover, the producer state move), so with the flag on +some programs fail closed. Follow +[Turning on role_isolation](../developer/role-isolation-runbook.md), and keep the +flag off until its rehearsal passes. + ### `[security.deepsec]` — the build-with-quality Security gate (ADR-2033) Runtime policy for `skills/build-with-quality/scripts/deepsec-gate.sh`, which drives the diff --git a/docs/user/sovereign-stack.md b/docs/user/sovereign-stack.md index 280bca65a..83650e1de 100644 --- a/docs/user/sovereign-stack.md +++ b/docs/user/sovereign-stack.md @@ -118,7 +118,9 @@ the DID document that solid-pod-rs's resolver will serve. ```sh # What identity does this container hold? -docker exec agentbox bash -lc 'source /run/agentbox/identity.env && env | grep AGENTBOX_' +# Public names only: with [security].role_isolation off, identity.env also +# exports AGENTBOX_NSEC and AGENTBOX_BRIDGE_SK, so never print all of it. +docker exec agentbox bash -lc 'source /run/agentbox/identity.env && env | grep -E "^AGENTBOX_(NPUB|DID|PUBKEY_HEX|X_ONLY_PUBKEY_HEX)="' # AGENTBOX_NPUB=npub1... # AGENTBOX_DID=did:nostr:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef # AGENTBOX_PUBKEY_HEX=... diff --git a/flake.nix b/flake.nix index da6cbc1a8..00cec4a27 100644 --- a/flake.nix +++ b/flake.nix @@ -261,7 +261,7 @@ inherit name enabled every ckWallet; mirror = enabled && (c.mirror or false); faucet = enabled && (c.faucet or false); - parent = c.parent; + inherit (c) parent; port = toString (c.port or 3451); interval = toString (c.interval or 600); parentRpc = unplaceheld (c.parent_rpc or "http://192.168.2.27:48342/"); @@ -1604,6 +1604,14 @@ # rebuild (its glibc store path is collected); see lib/sidestr-agent.nix. sidestrAgentPkg = import ./lib/sidestr-agent.nix { inherit lib; pkgs = rustPkgs; }; sidechainPackages = lib.optionals sidechainAnyFaucet [ sidestrAgentPkg ]; + # The producer's consensus code (spec, schema, blaketestnode) at the + # commits in upstream-pins, read-only; run-producer.sh runs it by default + # and a workspace checkout only under SIDESTR_ALLOW_UNPINNED=1 (custody + # W5, design §2.7). Evaluation fails if the two disagree. + sidestrUpstreamPkg = import ./lib/sidestr-upstream.nix { + inherit lib pkgs; + pinsFile = ./config/sidechain/upstream-pins; + }; # factrail — Jev compaction with fact rails ([features.jev_compaction], # ADR-2121). One pinned commit gives the binary and the # Claude Code shim that calls it; see lib/factrail.nix. @@ -1826,6 +1834,13 @@ default_days = ${toString (relayCfg.retention_days or 30)} # name a path, and a /nix/store path in prose rots at the next rebuild. ln -s ${vaultPkg}/bin/vault $out/opt/agentbox/bin/vault ''} + ${lib.optionalString sidechainEnabled '' + # The sidestr producer's baked upstream, at a stable path the runner + # defaults to (lib/sidestr-upstream.nix). [sidechain].enabled gates + # every chain program, so it gates the bake too. + mkdir -p $out/opt/agentbox/sidestr + ln -s ${sidestrUpstreamPkg} $out/opt/agentbox/sidestr/upstream + ''} ${lib.optionalString jevCompactionOn '' # factrail: the entrypoint projects THIS path as the plugin's `binary` # option, which persists in ~/.claude; a store path there would dangle. @@ -2084,7 +2099,9 @@ stderr_logfile=/var/log/ruvector-aggregate-sweep.error.log ; phone (Amethyst), executes them against the tmux fleet and DMs replies back. ; The operator secret (AGENTBOX_PRIVKEY_HEX) is injected into the process ; environment by the entrypoint launcher and inherited here — never written into -; the generated supervisor text. Off switch: AGENTBOX_NOSTR_GATEWAY=0. +; the generated supervisor text. Under [security].role_isolation it arrives as a +; file instead (AGENTBOX_PRIVKEY_HEX_FILE / AGENTBOX_BRIDGE_SK_FILE, custody W2) +; and the bare variable is refused. Off switch: AGENTBOX_NOSTR_GATEWAY=0. [program:nostr-gateway] command=${pkgs.nodejs_22}/bin/node /opt/agentbox/config/nostr-gateway/gateway.cjs directory=/opt/agentbox/config/nostr-gateway @@ -2456,8 +2473,13 @@ priority=15 stdout_logfile=/var/log/tailscaled.log stderr_logfile=/var/log/tailscaled.error.log +; Custody W2: under [security].role_isolation the entrypoint delivers the join +; key to TAILSCALE_AUTHKEY_FILE (root 0400) and unsets TAILSCALE_AUTHKEY, so the +; key is never in any process's environment or on argv: tailscale reads it via +; its own "file:" prefix. With the flag off the file var is unset and the +; original branch runs unchanged. [program:tailscale-up] -command=${pkgs.bash}/bin/bash -c "sleep 2 && if [ -n \"$TAILSCALE_AUTHKEY\" ]; then ${pkgs.tailscale}/bin/tailscale up --authkey=$TAILSCALE_AUTHKEY --hostname=${networkingCfg.hostname or "agentbox"} --accept-routes --ssh 2>&1; else echo 'No TAILSCALE_AUTHKEY set — run: docker exec agentbox tailscale up'; fi" +command=${pkgs.bash}/bin/bash -c "sleep 2 && if [ -n \"$TAILSCALE_AUTHKEY_FILE\" ] && [ -f \"$TAILSCALE_AUTHKEY_FILE\" ]; then ${pkgs.tailscale}/bin/tailscale up --authkey=file:$TAILSCALE_AUTHKEY_FILE --hostname=${networkingCfg.hostname or "agentbox"} --accept-routes --ssh 2>&1; elif [ -n \"$TAILSCALE_AUTHKEY\" ]; then ${pkgs.tailscale}/bin/tailscale up --authkey=$TAILSCALE_AUTHKEY --hostname=${networkingCfg.hostname or "agentbox"} --accept-routes --ssh 2>&1; else echo 'No TAILSCALE_AUTHKEY set — run: docker exec agentbox tailscale up'; fi" directory=/var/lib/tailscale environment=HOME="/home/devuser" autostart=true @@ -2467,6 +2489,56 @@ priority=16 stdout_logfile=/var/log/tailscale-up.log stderr_logfile=/var/log/tailscale-up.error.log ''} + +[program:docker-read-proxy] +; Custody X-1 step 1, W0 (owner question Q1 default). Under +; [security].role_isolation = true devuser loses the raw host Docker socket and +; gets this GET-only proxy at /run/docker-ro.sock: ps/logs/inspect/version/info +; pass, exec/run/create/cp/export get 403. With the flag off (the default) the +; program prints one line and exits 0, so status shows EXITED; that is expected. +; No user= on purpose: it starts as root only to bind the socket and join the +; host socket's group, then drops to uid/gid 65534 itself. `env -i` keeps the +; .env secrets that PID 1 inherits out of this process (design §0 bypass 3). +; TODO(custody W1): move into the role registry (lib/role-accounts.nix) with its +; own account; until then it follows the devuser-block layout, minus user=. +command=${pkgs.bash}/bin/bash -c 'exec ${pkgs.coreutils}/bin/env -i AGENTBOX_ROLE_ISOLATION="''${AGENTBOX_ROLE_ISOLATION:-0}" ${pkgs.nodejs_22}/bin/node /opt/agentbox/config/docker-read-proxy.cjs' +directory=/ +autostart=true +autorestart=unexpected +exitcodes=0 +startsecs=0 +priority=20 +stdout_logfile=/var/log/docker-read-proxy.log +stderr_logfile=/var/log/docker-read-proxy.error.log +${lib.optionalString (nostrPodBridgePkg != null) '' + +[program:serve-identity] +; Custody X-1 step 1, W3: the identity port (design §2.5; ADR-2122). Holds the +; core and JunkieJarvis keys and signs named operations for the callers in +; config/custody/identity-port-acl.json, authorised by SO_PEERCRED uid. +; Today's config runs it as devuser, and with [security].role_isolation off it +; prints one line and exits 0 (status EXITED, expected), like docker-read-proxy. +; With the flag on, `agentbox-manifest role-accounts isolate` rewrites user= to +; ab-identity and sets AGENTBOX_SECRETS_DIR=/run/secrets/ab-identity (where the +; delivery plan wrote AGENTBOX_PRIVKEY_HEX and JUNKIEJARVIS_PRIVKEY_HEX) and +; AGENTBOX_IDENTITY_SOCK_GID (the ab-identity-port group) in environment=; the +; command passes exactly those through `env -i`, so PID 1's inherited .env +; (design §0 bypass 3) never reaches this process. The socket dir +; /run/secrets/ab-identity-port/ (0750) is made by the delivery plan inside the +; root-owned secrets mount: devuser owns /run and could rename anything else. +command=${pkgs.bash}/bin/bash -c 'exec ${pkgs.coreutils}/bin/env -i AGENTBOX_ROLE_ISOLATION="''${AGENTBOX_ROLE_ISOLATION:-0}" RUST_LOG=info AGENTBOX_IDENTITY_ACL=/opt/agentbox/config/custody/identity-port-acl.json AGENTBOX_IDENTITY_KEY_DIR="''${AGENTBOX_SECRETS_DIR:-/run/secrets/ab-identity}" AGENTBOX_IDENTITY_SOCK=/run/secrets/ab-identity-port/identity.sock AGENTBOX_IDENTITY_SOCK_GID="''${AGENTBOX_IDENTITY_SOCK_GID:-}" AGENTBOX_IDENTITY_RECEIPT_DIR=/var/lib/agentbox/events/sign AGENTBOX_CONFIG=/etc/agentbox.toml ${nostrPodBridgePkg}/bin/nostr-pod-bridge serve-identity' +directory=/ +user=devuser +environment=HOME="/home/devuser" +autostart=true +autorestart=unexpected +exitcodes=0 +startsecs=0 +priority=30 +stopsignal=TERM +stdout_logfile=/var/log/serve-identity.log +stderr_logfile=/var/log/serve-identity.error.log +''} ${lib.optionalString (toolchainCfg.code_server or false) '' [program:code-server] @@ -2663,9 +2735,10 @@ ${lib.optionalString sidechainEnabled '' ; [sidechain] (PRD-024 P1): the sidestr chain producer, which the forum's ; member wallets read (dreamlab-ai-website, forum ADR-2015). It ran in a tmux ; window until 2026-09-25, when a container restart stopped the chain for four -; days with nothing to bring it back. run-producer.sh refuses to start unless -; the upstream checkouts match config/sidechain/upstream-pins, so a failed -; start means a pin to fix, not a restart loop: startretries caps it at FATAL. +; days with nothing to bring it back. run-producer.sh runs the baked upstream +; (/opt/agentbox/sidestr/upstream, lib/sidestr-upstream.nix) and refuses to +; start unless its recorded commits match config/sidechain/upstream-pins, so a +; failed start means a pin to fix, not a restart loop: startretries caps it at FATAL. ; The runner and the sealed chain document are baked; the block file is the ; workspace's (SIDESTR_STATE). [program:sidestr-producer] @@ -2720,7 +2793,7 @@ ${lib.concatMapStrings (c: lib.optionalString c.enabled '' ; [sidechain.${c.name}] (ADR-2103): sidestr:${c.name}'s producer beside ${c.parent}. ; Same runner as sidestr:dreamlab's, keyed by SIDESTR_CHAIN. It refuses to start -; on an unpinned upstream checkout, on a document sealed beside a parent other +; on a baked upstream that disagrees with upstream-pins, on a document sealed beside a parent other ; than the manifest's (D3), and on a BLAKE2b parent whose block at the fork ; height is not the fork hash (D3a). checkpoint_every = ${toString c.every}${lib.optionalString (c.every == 0) ": no checkpoint, so nothing anchors this chain (owner SC5, Open in ADR-2103)"}. [program:sidestr-producer-${c.name}] @@ -3149,6 +3222,11 @@ stderr_logfile_maxbytes=5MB securityCapsRaiseAttackSurface = (exceptionCapAdd != []) || (exceptionSecurityOptOverrides != []); auditAcknowledged = securityCfg.audit_acknowledged or false; + # Custody W10 (ADR-2122): [security].role_isolation as this image was BUILT. + # The flag is boot-class for everything the entrypoint can pick at runtime; + # devuser's wheel membership and sudoers drop-in live in the read-only /etc, + # so they follow the build-time value (config/bake-devuser-privilege.sh). + roleIsolationBaked = securityCfg.role_isolation or false; _w021Check = if securityCapsRaiseAttackSurface && !auditAcknowledged then throw '' @@ -3198,6 +3276,11 @@ stderr_logfile_maxbytes=5MB # acrobatics. Bootstrap-as-root still has CAP_CHOWN baseline # cap if it needs to fix anything. "/run:mode=755,size=${resTmpfsRun},uid=1000,gid=1000" + # ADR-2122 (custody W1): /run/secrets is its OWN root-owned tmpfs, so + # devuser, owner of /run, cannot rename or replace it. Ships in both + # modes; with [security].role_isolation off the entrypoint chowns it to + # devuser 0700 exactly as before. + "/run/secrets:mode=711,size=8M,uid=0,gid=0,noexec,nosuid,nodev" "/var/run:mode=755,size=16M,uid=1000,gid=1000" "/var/log:mode=755,size=128M,uid=1000,gid=1000" "/var/log/supervisor:mode=755,size=64M,uid=1000,gid=1000" @@ -3490,6 +3573,14 @@ ${ragflowNetworkDecl} "legacy JSS environment leaked into the agentbox supervisor config"; supervisorText )} $out/etc/supervisord.conf + # ADR-2122 (custody W1): the isolated config and the /run/secrets + # delivery plan are a pure function of the config above and + # config/role-accounts.json, so the two configs cannot drift + # (tests/config/role-isolation-supervisor.test.sh). Both ship in every + # image; the entrypoint picks one from [security].role_isolation. + ${agentboxManifestPkg}/bin/agentbox-manifest role-accounts isolate --table ${./config/role-accounts.json} --conf $out/etc/supervisord.conf --out $out/etc/supervisord.roles.conf --plan $out/etc/agentbox/role-secrets.tsv + # The table itself, for the role-isolation rehearsal and operators (names and paths only). + cp ${./config/role-accounts.json} $out/etc/agentbox/role-accounts.json cp ${./agentbox.toml} $out/etc/agentbox.toml cp ${pkgs.writeText "docker-compose.yml" composeText} $out/etc/agentbox/docker-compose.yml ${lib.optionalString relayLocal '' @@ -3505,23 +3596,31 @@ ${ragflowNetworkDecl} PASSWD # Strip leading whitespace introduced by Nix heredoc indentation sed -i 's/^[[:space:]]*//' $out/etc/passwd - + # ADR-2122 (custody W1): one account per role, uid = gid in 960-979, + # from config/role-accounts.json. Inert unless [security].role_isolation. + ${agentboxManifestPkg}/bin/agentbox-manifest role-accounts passwd --table ${./config/role-accounts.json} >> $out/etc/passwd + + # Custody W0: devuser is NOT a member of group root. Membership gave + # every devuser process gid 0 and with it anything root left + # group-accessible. Undoing this needs a rebuild (it is baked). + # Test: tests/runtime-contract/RC-X1-03.sh. cat > $out/etc/group <<'GROUP' - root:x:0:devuser + root:x:0: wheel:x:998:devuser devuser:x:1000: GROUP sed -i 's/^[[:space:]]*//' $out/etc/group - - # Passwordless sudo for devuser. Both /etc/sudoers and the drop-in - # are baked into the image because the rootfs is read_only at runtime - # — there's no place for the entrypoint to write these. - echo "root ALL=(ALL) ALL" > $out/etc/sudoers - echo "#includedir /etc/sudoers.d" >> $out/etc/sudoers - chmod 440 $out/etc/sudoers - mkdir -p $out/etc/sudoers.d - echo "devuser ALL=(ALL) NOPASSWD: ALL" > $out/etc/sudoers.d/devuser - chmod 440 $out/etc/sudoers.d/devuser + # ADR-2122: each role's own primary group, with no members. + ${agentboxManifestPkg}/bin/agentbox-manifest role-accounts group --table ${./config/role-accounts.json} >> $out/etc/group + + # devuser's sudo route. /etc/sudoers and the drop-in are baked + # because the rootfs is read_only at runtime. Custody W10: built with + # [security].role_isolation off, devuser stays in wheel with today's + # passwordless drop-in (byte-identical); built with it on, devuser + # leaves wheel (and root) and there is no drop-in. An image built off + # and booted on reports degraded:devuser-sudo until rebuilt + # (_ab_devuser_privilege_check). Test: tests/runtime-contract/RC-X1-07.sh. + ${pkgs.bash}/bin/bash ${./config/bake-devuser-privilege.sh} $out/etc ${if roleIsolationBaked then "1" else "0"} # Shell-rc seeding (Q23). Baked into the image at build time so # interactive devuser shells consistently source the agentbox diff --git a/lib/agentbox-manifest.nix b/lib/agentbox-manifest.nix index 574786221..424a0b7c2 100644 --- a/lib/agentbox-manifest.nix +++ b/lib/agentbox-manifest.nix @@ -66,8 +66,11 @@ pkgs.rustPlatform.buildRustPackage { cp -R ${tuiFixturesSrc}/. tests/tui/fixtures/ cp ${../config/entrypoint-unified.sh} tests/entrypoint-unified.sh cp ${../config/registered-hooks.txt} registered-hooks.txt + cp ${../config/role-accounts.json} role-accounts.json substituteInPlace src/hooks.rs \ --replace-fail '../../../config/registered-hooks.txt' '../registered-hooks.txt' + substituteInPlace src/role_accounts.rs \ + --replace-fail '"../../config/role-accounts.json"' '"role-accounts.json"' substituteInPlace tests/consultant_model.rs \ --replace-fail '../../../config/entrypoint-unified.sh' 'entrypoint-unified.sh' substituteInPlace tests/golden.rs \ diff --git a/lib/sidestr-upstream.nix b/lib/sidestr-upstream.nix new file mode 100644 index 000000000..a7abb824a --- /dev/null +++ b/lib/sidestr-upstream.nix @@ -0,0 +1,96 @@ +# lib/sidestr-upstream.nix +# +# The sidestr producer's consensus code, baked at the commits in +# config/sidechain/upstream-pins (custody design §2.7, owner Q7; ADR-2103). +# Linked at /opt/agentbox/sidestr/upstream when [sidechain].enabled, and run by +# config/sidechain/run-producer.sh by default. +# +# Why baked and not run from the workspace checkouts: the producer holds the +# chain's signing key, and code its user can edit can exfiltrate that key. The +# old pin check compared `git rev-parse HEAD`, so an uncommitted edit to a +# checkout passed it. A /nix/store tree is root-owned on a read-only rootfs: +# nothing in the container can change it, and the pin becomes structural. +# +# The output is one tree laid out as run-producer.sh expects: +# spec/ sidestr/spec (siding, the reference engine) +# schema/ bitcoin-desktop/schema (the kernel siding loads via SCHEMA) +# blaketestnode/ bitcoin-blake/blaketestnode (block files, via BLAKETESTNODE) +# Each directory carries .pin-commit, the commit it was fetched at; the runner +# refuses to start when any differs from upstream-pins (a stale bake). +# +# node_modules: none. siding imports only node: builtins on the producer path. +# Its package.json names five @ethereumjs packages, but evm.mjs imports them +# lazily and only for a chain whose document names the `evm` rule; neither +# estate chain (config/sidechain/*/chain.json) has a `rules` key. blaketestnode's +# one dependency, webtorrent, is imported only by lib/fetch.mjs, which the +# producer never loads. The hand-placed @ethereumjs/common in the workspace +# checkout was never sufficient for the rule anyway (it needs vm, tx, block, +# util and statemanager). run-producer.sh refuses an evm chain on this bake; +# adopting the rule means baking siding/package-lock.json here first. +# +# To bump: move the line in upstream-pins (only after a block on the new +# commit, as that file says), then set the same `rev` here and refresh `hash` +# (nix-prefetch-url --unpack https://github.com///archive/.tar.gz, +# then `nix hash convert --to sri`). Evaluation fails while the two disagree. +# +# Licence of this file: AGPL-3.0-only, the same as agentbox. The fetched trees +# keep their own (spec: see its LICENSE; schema: AGPL-3.0-or-later; +# blaketestnode: MIT). + +{ lib, pkgs, pinsFile }: + +let + # Hashes are fetchFromGitHub narHashes of each commit's GitHub tarball. + upstreams = { + spec = { + owner = "sidestr"; + repo = "spec"; + rev = "e8deb63161c7459ed39c01d2ca9fda3d860b65b6"; + hash = "sha256-ZCk7VtqVg7vo4Gg7MR6fBW2kZW/uPJ8W7OQbNwsi0XA="; + }; + schema = { + owner = "bitcoin-desktop"; + repo = "schema"; + rev = "b8cbf6337c7450fe14ddc5bce00c7280059aab5d"; + hash = "sha256-FidkmSgBdY0JIitAok9/g1ea/K/qAO2rTtea2Ro3Jp0="; + }; + blaketestnode = { + owner = "bitcoin-blake"; + repo = "blaketestnode"; + rev = "de33b347ccde458f5b82c33b3bab16abd328a7ec"; + hash = "sha256-xATkIUSxg8EW0493YL68Aa8sLIuRfpKGd2nnnDb+FfE="; + }; + }; + + # upstream-pins: ` # comment` per line. + fields = l: lib.filter (f: builtins.isString f && f != "") (builtins.split "[[:space:]]+" l); + pinEntries = lib.filter (f: f != [ ] && !(lib.hasPrefix "#" (builtins.head f))) + (map fields (lib.splitString "\n" (builtins.readFile pinsFile))); + pins = builtins.listToAttrs (map (f: + { name = builtins.elemAt f 0; value = builtins.elemAt f 1; }) pinEntries); + + checked = + if lib.attrNames pins != lib.attrNames upstreams then + throw "lib/sidestr-upstream.nix bakes ${toString (lib.attrNames upstreams)} but upstream-pins names ${toString (lib.attrNames pins)}" + else lib.mapAttrs (dir: u: + if pins.${dir} != u.rev then + throw "lib/sidestr-upstream.nix bakes ${dir} at ${u.rev} but upstream-pins says ${pins.${dir}}; move rev and hash together" + else u) upstreams; + + srcs = lib.mapAttrs (_: u: pkgs.fetchFromGitHub { inherit (u) owner repo rev hash; }) checked; + +in +pkgs.runCommand "sidestr-upstream-${builtins.substring 0 8 checked.spec.rev}" { + passthru = { inherit srcs; pins = lib.mapAttrs (_: u: u.rev) checked; }; + meta = { + description = "sidestr producer upstream (spec, schema, blaketestnode) at config/sidechain/upstream-pins"; + platforms = lib.platforms.all; + }; +} '' + mkdir -p $out + ${lib.concatStrings (lib.mapAttrsToList (dir: src: '' + cp -r ${src} $out/${dir} + chmod -R u+w $out/${dir} + printf '%s\n' ${checked.${dir}.rev} > $out/${dir}/.pin-commit + '') srcs)} +'' diff --git a/management-api/lib/agent-identity.js b/management-api/lib/agent-identity.js index dbacdfd47..48e64b1a8 100644 --- a/management-api/lib/agent-identity.js +++ b/management-api/lib/agent-identity.js @@ -48,6 +48,7 @@ const fs = require('fs'); const path = require('path'); +const roleSecret = require('./role-secret'); let nostrTools = null; function getNostrTools() { @@ -112,7 +113,9 @@ function profileKeyPath(opts = {}) { * Mint or load a per-agent did:nostr, persisting the private key per profile. * * Precedence for the private key: (1) an explicit - * `AGENTBOX_AGENT_PRIVKEY_HEX` env override (stable-identity injection), then + * `AGENTBOX_AGENT_PRIVKEY_HEX` override (stable-identity injection), read + * through role-secret.js: `_FILE` first, the bare variable only while + * role isolation is off; then * (2) the persisted profile key file, then (3) a freshly generated key. * * @param {object} [opts] @@ -128,7 +131,10 @@ function loadOrMint(opts = {}) { let privHex = null; let minted = false; - const envHex = String(process.env.AGENTBOX_AGENT_PRIVKEY_HEX || '').trim().toLowerCase(); + // Custody W2: AGENTBOX_AGENT_PRIVKEY_HEX_FILE wins; the bare variable is + // honoured only while [security].role_isolation is off. + const envHex = String(roleSecret.readRoleSecret('AGENTBOX_AGENT_PRIVKEY_HEX', opts.roleSecretOpts) || '') + .trim().toLowerCase(); if (HEX64.test(envHex)) { privHex = envHex; } @@ -269,6 +275,11 @@ module.exports = { sovereignIdentityPath, loadSovereignSigner, MULTIKEY_PREFIX, + // Custody W2: the shared ROLE-secret loader (see role-secret.js). + roleIsolation: roleSecret.roleIsolation, + readRoleSecret: roleSecret.readRoleSecret, + readRoleSecretFirst: roleSecret.readRoleSecretFirst, + resolveRoleSecret: roleSecret.resolveRoleSecret, }; // ─── CLI (env-gated shell integration) ────────────────────────────────────── diff --git a/management-api/lib/junkiejarvis-agent.js b/management-api/lib/junkiejarvis-agent.js index 766f57864..472334c87 100644 --- a/management-api/lib/junkiejarvis-agent.js +++ b/management-api/lib/junkiejarvis-agent.js @@ -43,6 +43,7 @@ const crypto = require('crypto'); const zoneKeys = require('./zone-keys'); +const { readRoleSecretFirst } = require('./role-secret'); const { junkiejarvisEnabled } = require('./junkiejarvis-clarify'); let nostrTools = null; @@ -82,9 +83,19 @@ const PROFILE_METADATA = Object.freeze({ const VALID_ZONES = Object.freeze(['public', 'friends', 'family', 'business']); const VALID_VENUES = Object.freeze(['fairfield', 'dreamlab']); -/** Private key env var (new name), with a transition fallback to the old one. */ -function readPrivHex() { - return process.env.JUNKIEJARVIS_PRIVKEY_HEX || process.env.CONCIERGE_PRIVKEY_HEX || ''; +/** + * The JunkieJarvis private key (new name), with a transition fallback to the + * old one. Custody W2: read through role-secret.js, so `_FILE` wins and + * the bare variables are honoured only while [security].role_isolation is off. + * An unreadable key file reads as no key (the agent then does not start). + */ +function readPrivHex(opts = {}) { + try { + return readRoleSecretFirst(['JUNKIEJARVIS_PRIVKEY_HEX', 'CONCIERGE_PRIVKEY_HEX'], opts).value; + } catch (err) { + (opts.logger || console).warn(`junkiejarvis: ${err.message}`); + return ''; + } } // ─── Personality ──────────────────────────────────────────────────────────── diff --git a/management-api/lib/pod-signer.js b/management-api/lib/pod-signer.js index 2cd6e5ed6..03e7611e0 100644 --- a/management-api/lib/pod-signer.js +++ b/management-api/lib/pod-signer.js @@ -36,6 +36,16 @@ * - `sign_requests` ON → a `null` header (key absent, unreadable, * inconsistent) makes every request throw `SigningUnavailable`. * + * **Identity port (custody X-1 step 1, ADR-2122).** Under + * `[security].role_isolation` (manifest, or `AGENTBOX_ROLE_ISOLATION=1` as the + * entrypoint exports it) — or when `[integrations.solid_pod_rs].sign_source = + * "identity-port"` names it explicitly — the header comes from the identity + * port: `nostr-pod-bridge sign-request nip98` over the unix socket, authorised + * by this process's uid. It is then the ONLY source: no key file and no stack + * key is ever read, so this process holds no key at all. A refused or absent + * port (client exit 1 or 2, or any failure to answer) throws, and the adapter + * turns that into `SigningUnavailable` with zero bytes sent (ADR-2064). + * * There is no dev-profile relaxation: as recorded at ADR-2041, a grep across * agentbox for `AGENTBOX_DEV*` / `dev_profile` / `dev_mode` / `dev-profile` * finds no such flag, and ADR-2064 does not invent one. @@ -53,6 +63,8 @@ * @param {object} [deps.env] - Environment override (defaults to process.env). * @param {object} [deps.signerOpts] - Passed through to the selected loader. * @param {Function} [deps.onError] - Invoked once if the key load fails. + * @param {Function} [deps.signRequest] - `(op, params, env) => Promise<{code, out}>` + * identity-port client override (defaults to spawning `nostr-pod-bridge sign-request`). * @returns {(null|function(string,string,*):Promise)} */ function buildPodNip98(manifest, deps = {}) { @@ -61,6 +73,9 @@ function buildPodNip98(manifest, deps = {}) { if (!integ.sign_requests) return null; const env = deps.env || process.env; + if (identityPortSelected(manifest, integ, env)) { + return buildPortNip98(deps.signRequest || spawnSignRequest, env); + } const stack = env.AGENTBOX_STACK || integ.sign_stack || null; let bridge = null; @@ -113,4 +128,72 @@ function buildPodNip98(manifest, deps = {}) { }; } -module.exports = { buildPodNip98 }; +/** Identity-port source selection: the flag forces it; `sign_source` may name it. */ +function identityPortSelected(manifest, integ, env) { + const sec = (manifest && manifest.security) || {}; + if (sec.role_isolation === true || String(env.AGENTBOX_ROLE_ISOLATION || '').trim() === '1') return true; + return integ.sign_source === 'identity-port'; +} + +/** hex(sha256(body)) exactly as NostrBridge.buildNip98Header hashes it, or null. */ +function payloadSha256(body) { + if (body === undefined || body === null || body === '') return null; + const buf = Buffer.isBuffer(body) + ? body + : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8'); + if (buf.length === 0) return null; + return require('crypto').createHash('sha256').update(buf).digest('hex'); +} + +/** + * The identity-port originator. Every failure throws with the port's own + * reason; the reason never carries key material (the port holds none to give). + */ +function buildPortNip98(signRequest, env) { + return async function nip98(method, url, body) { + const params = { key: 'core', method: String(method).toUpperCase(), url: String(url) }; + const payload = payloadSha256(body); + if (payload) params.payload_sha256 = payload; + const { code, out } = await signRequest('nip98', params, env); + let res = null; + try { res = JSON.parse(String(out || '')); } catch { /* reported below */ } + if (code === 0 && res && typeof res.header === 'string' && res.header.startsWith('Nostr ')) { + return res.header; + } + const why = res && res.refused + ? `refused: ${res.refused.reason || 'no reason'}` + : res && res.unavailable + ? `unavailable: ${res.unavailable.reason || 'no reason'}` + : `client exit ${code}`; + throw new Error(`identity port ${why}`); + }; +} + +/** Default client: `nostr-pod-bridge sign-request `, params on stdin. */ +function spawnSignRequest(op, params, env) { + const { spawn } = require('child_process'); + const bin = env.AGENTBOX_POD_BRIDGE_BIN || 'nostr-pod-bridge'; + return new Promise((resolve) => { + let out = ''; + let settled = false; + const done = (r) => { if (!settled) { settled = true; resolve(r); } }; + let child; + try { + child = spawn(bin, ['sign-request', op], { env, stdio: ['pipe', 'pipe', 'ignore'] }); + } catch (err) { + done({ code: 2, out: JSON.stringify({ unavailable: { reason: `cannot run ${bin}: ${err.code || err.message}` } }) }); + return; + } + const timer = setTimeout(() => { child.kill('SIGKILL'); done({ code: 2, out: '' }); }, 15000); + child.stdout.on('data', (d) => { out += d; }); + child.on('error', (err) => { + clearTimeout(timer); + done({ code: 2, out: JSON.stringify({ unavailable: { reason: `cannot run ${bin}: ${err.code || err.message}` } }) }); + }); + child.on('close', (code) => { clearTimeout(timer); done({ code: code === null ? 2 : code, out }); }); + child.stdin.on('error', () => {}); + child.stdin.end(JSON.stringify(params)); + }); +} + +module.exports = { buildPodNip98, identityPortSelected }; diff --git a/management-api/lib/role-secret.js b/management-api/lib/role-secret.js new file mode 100644 index 000000000..35d844f5d --- /dev/null +++ b/management-api/lib/role-secret.js @@ -0,0 +1,180 @@ +'use strict'; + +/** + * role-secret — the one JS loader for ROLE secrets (custody X-1 step 1, W2, + * bypass 3). Re-exported by agent-identity.js; hooks and scripts that live + * outside management-api require this file directly. + * + * A ROLE variable (the ROLE set of config/custody/env-classes.json) holds a + * signing or join key that must not sit in every process's environment. Under + * [security].role_isolation the entrypoint writes each one to a file, exports + * `_FILE`, and unsets `` before supervisord starts. + * + * Precedence, identical to the Rust loader + * (services/nostr-pod-bridge/src/role_secret.rs): + * 1. `_FILE`, when set and non-empty, wins. Its contents are read + * (bounded, trimmed). An unreadable file throws, naming the path, never + * falling back. + * 2. `$AGENTBOX_SECRETS_DIR/`, when that file exists. W1's isolated + * supervisor config sets AGENTBOX_SECRETS_DIR for each role program to its + * /run/secrets/, where the delivery plan writes env-sourced secrets + * under their variable's name (config/role-accounts.json). + * 3. `opts.defaultFile`, when it exists. + * 4. `` itself, ONLY while the flag is off. Under the flag a ROLE + * variable present at all is reported as `ROLE-ISOLATION-LEAK ` and + * ignored. + * + * The flag is AGENTBOX_ROLE_ISOLATION=1, exported by the entrypoint from the + * manifest. Nothing here ever logs, returns in an error, or prints a value. + */ + +const fs = require('fs'); +const path = require('path'); + +const FLAG_VAR = 'AGENTBOX_ROLE_ISOLATION'; +const SECRETS_DIR_VAR = 'AGENTBOX_SECRETS_DIR'; +/** Keys and tokens are far below this; a bigger file is a misconfiguration. */ +const MAX_SECRET_FILE_BYTES = 4096; + +const reported = new Set(); + +function roleIsolation(env = process.env) { + return String(env[FLAG_VAR] ?? '') === '1'; +} + +function fileVar(name) { + return `${name}_FILE`; +} + +/** + * Read a secret file: a regular file of at most MAX_SECRET_FILE_BYTES, UTF-8, + * trimmed. Errors name the path only. + * + * @param {string} file + * @param {object} [fsImpl] - injectable fs (tests) + * @returns {string} + */ +function readSecretFile(file, fsImpl = fs) { + let fd; + try { + fd = fsImpl.openSync(file, 'r'); + } catch (err) { + throw new Error(`secret file ${file} cannot be opened (${err.code || 'error'})`); + } + try { + const st = fsImpl.fstatSync(fd); + if (!st.isFile()) throw new Error(`secret file ${file} is not a regular file`); + if (st.size > MAX_SECRET_FILE_BYTES) { + throw new Error(`secret file ${file} is larger than ${MAX_SECRET_FILE_BYTES} bytes`); + } + const buf = Buffer.alloc(MAX_SECRET_FILE_BYTES + 1); + const n = fsImpl.readSync(fd, buf, 0, buf.length, 0); + if (n > MAX_SECRET_FILE_BYTES) { + throw new Error(`secret file ${file} is larger than ${MAX_SECRET_FILE_BYTES} bytes`); + } + const text = buf.subarray(0, n).toString('utf8').trim(); + buf.fill(0); + return text; + } finally { + fsImpl.closeSync(fd); + } +} + +function defaultLog(line) { + process.stderr.write(`${line}\n`); +} + +/** + * Resolve `name` without logging. + * + * @param {string} name + * @param {object} [opts] + * @param {object} [opts.env=process.env] + * @param {string} [opts.defaultFile] + * @param {object} [opts.fs] + * @returns {{value:string, source:('file'|'env'|null), file:(string|null), leaked:boolean}} + */ +function resolveRoleSecret(name, opts = {}) { + const env = opts.env || process.env; + const fsImpl = opts.fs || fs; + const isolated = roleIsolation(env); + const leaked = isolated && Object.prototype.hasOwnProperty.call(env, name); + let file = String(env[fileVar(name)] ?? '').trim() || null; + const isFile = (p) => { try { return fsImpl.statSync(p).isFile(); } catch (_) { return false; } }; + const secretsDir = String(env[SECRETS_DIR_VAR] ?? '').trim(); + if (!file && secretsDir && isFile(path.join(secretsDir, name))) file = path.join(secretsDir, name); + if (!file && opts.defaultFile) { + try { + if (fsImpl.statSync(opts.defaultFile).isFile()) file = opts.defaultFile; + } catch (_) { /* absent default: fall through */ } + } + if (file) { + let value; + try { + value = readSecretFile(file, fsImpl); + } catch (err) { + throw new Error(`resolving ${name}: ${err.message}`); + } + return { value, source: value ? 'file' : null, file, leaked }; + } + if (isolated) return { value: '', source: null, file: null, leaked }; + const value = String(env[name] ?? '').trim(); + return { value, source: value ? 'env' : null, file: null, leaked: false }; +} + +function reportLeak(name, log) { + const key = name; + if (reported.has(key)) return; + reported.add(key); + log(`[role-secret] ROLE-ISOLATION-LEAK ${name} is set in the environment under role isolation; ignored (deliver it as ${fileVar(name)})`); +} + +/** + * The value of ROLE variable `name` ('' when none), reporting a leak once per + * process. Throws when `_FILE` is set but unreadable. + * + * @param {string} name + * @param {object} [opts] - see resolveRoleSecret; opts.log replaces stderr. + * @returns {string} + */ +function readRoleSecret(name, opts = {}) { + const r = resolveRoleSecret(name, opts); + if (r.leaked) reportLeak(name, opts.log || defaultLog); + return r.value; +} + +/** + * The first of `names` that resolves, with the name that supplied it. Every + * name is checked for a leak. + * + * @param {string[]} names + * @param {object} [opts] + * @returns {{value:string, name:(string|null)}} + */ +function readRoleSecretFirst(names, opts = {}) { + let found = { value: '', name: null }; + for (const name of names) { + const r = resolveRoleSecret(name, opts); + if (r.leaked) reportLeak(name, opts.log || defaultLog); + if (!found.name && r.value) found = { value: r.value, name }; + } + return found; +} + +/** Test seam: forget which leaks were already reported. */ +function _resetReported() { + reported.clear(); +} + +module.exports = { + FLAG_VAR, + SECRETS_DIR_VAR, + MAX_SECRET_FILE_BYTES, + roleIsolation, + fileVar, + readSecretFile, + resolveRoleSecret, + readRoleSecret, + readRoleSecretFirst, + _resetReported, +}; diff --git a/management-api/lib/system-manifest.js b/management-api/lib/system-manifest.js index d07a83761..e102e4fdd 100644 --- a/management-api/lib/system-manifest.js +++ b/management-api/lib/system-manifest.js @@ -91,6 +91,9 @@ const CATALOGUE = [ { id: 'deepsec', name: 'deepsec security gate', layer: 'module', gates: ['toolchains.deepsec', 'security.deepsec.enabled'], apply_class: 'rebuild', summary: 'ADR-2033: vercel-labs deepsec vulnerability reviewer baked as a CLI; build-with-quality drives it in PR mode via scripts/deepsec-gate.sh under the [security.deepsec] policy.' }, + { id: 'role-isolation', name: 'Role service accounts + /run/secrets (custody X-1 step 1)', layer: 'module', + gate: 'security.role_isolation', apply_class: 'boot', + summary: 'ADR-2122: role programs run under per-role uids (config/role-accounts.json, 960-979) from /etc/supervisord.roles.conf, read their secrets from the root-owned /run/secrets//, and the classified variables leave PID 1; devuser loses the raw Docker socket (W0). Boot-class: accounts, both supervisor configs and the mount ship in every image and the entrypoint picks at boot (the image that first carries them needs one ./agentbox.sh rebuild). Off until the role-isolation rehearsal passes.' }, { id: 'codebase-memory', name: 'codebase-memory MCP', layer: 'module', gate: 'toolchains.codebase_memory', apply_class: 'rebuild', summary: 'Structural code-graph index MCP (callers, architecture, snippets).' }, @@ -261,7 +264,7 @@ const CATALOGUE = [ summary: 'ADR-2057 gap 1: [program:podcast-cron] (supercronic over skills/podcast-knowledge-ingest/crontab) was the last unconditionally supervised program. Default true = the behaviour it shipped with, so enabling is never a migration step. REBUILD-class — flake.nix bakes the supervisor text, so false only removes the program after ./agentbox.sh rebuild. Gates the SCHEDULE only: the podcast-ingest binary and supercronic stay in the closure, both shared with always-baked surfaces (the podcast-{knowledge,bulk}-ingest skills and forum-backup-cron).' }, { id: 'sidechain', name: 'sidestr sidechain producer, mirror and faucet', layer: 'module', gate: 'sidechain.enabled', gates: ['sidechain.enabled', 'sidechain.mirror', 'sidechain.faucet'], service: 'sidestr-producer', apply_class: 'rebuild', - summary: 'PRD-024 P1: [program:sidestr-producer] (config/sidechain/run-producer.sh, the upstream JS engine at the commits in upstream-pins) makes the blocks the forum member wallets read; mirror adds [program:sidestr-mirror] (block files to a GitHub Pages checkout), faucet adds [program:sidestr-faucet] (sidestr-agent, baked from lib/sidestr-agent.nix). mirror and faucet take effect only with enabled. REBUILD-class: flake.nix bakes all three supervisor blocks. Needs the chain signer key and parent cookie under /var/lib/agentbox/secrets. Health is the tip-age probe (lib/sidechain-health.js, ADR-2103 interim receipt 2026-10-02), not supervisor RUNNING: unhealthy when the producer status route is unreachable, the tip is older than 2 x the idle interval (600 s), or transactions have waited longer than 2 x the transaction interval (10 s) with no block. `health.anchored` is false while checkpoints into the parent are off (owner decision 2026-10-02, SC5).' }, + summary: 'PRD-024 P1: [program:sidestr-producer] (config/sidechain/run-producer.sh, the upstream JS engine baked read-only at the commits in upstream-pins by lib/sidestr-upstream.nix; a workspace checkout runs only under SIDESTR_ALLOW_UNPINNED=1 and logs SIDESTR-UNPINNED) makes the blocks the forum member wallets read; mirror adds [program:sidestr-mirror] (block files to a GitHub Pages checkout), faucet adds [program:sidestr-faucet] (sidestr-agent, baked from lib/sidestr-agent.nix). mirror and faucet take effect only with enabled. REBUILD-class: flake.nix bakes all three supervisor blocks. Needs the chain signer key and parent cookie under /var/lib/agentbox/secrets. Health is the tip-age probe (lib/sidechain-health.js, ADR-2103 interim receipt 2026-10-02), not supervisor RUNNING: unhealthy when the producer status route is unreachable, the tip is older than 2 x the idle interval (600 s), or transactions have waited longer than 2 x the transaction interval (10 s) with no block. `health.anchored` is false while checkpoints into the parent are off (owner decision 2026-10-02, SC5).' }, { id: 'sidechain-dreamlab-txbt4', name: 'sidestr:dreamlab-txbt4 producer, mirror and faucet (beside txbt4)', layer: 'module', requires: ['sidechain.enabled'], gate: 'sidechain.dreamlab-txbt4.enabled', gates: ['sidechain.dreamlab-txbt4.enabled', 'sidechain.dreamlab-txbt4.mirror', 'sidechain.dreamlab-txbt4.faucet'], diff --git a/management-api/package.json b/management-api/package.json index 130d29e02..61d2be767 100644 --- a/management-api/package.json +++ b/management-api/package.json @@ -7,7 +7,7 @@ "start": "node server.js", "dev": "node server.js", "test": "jest && npm run test:node", - "test:node": "node --test ../tests/contract/harness-bridge.contract.spec.js ../tests/contract/upstream_vectors/all_fixtures.test.js ../tests/sovereign/zone-keys.node-test.js ../tests/sovereign/nip98-host-port.node-test.js ../tests/sovereign/chain-info.node-test.js ../tests/sovereign/pod-sovereign-signer.node-test.js ../tests/sovereign/sidestr-spend-key.node-test.js ../tests/sovereign/chain-payments.node-test.js ../tests/sovereign/dispatch-as.node-test.js ../tests/sovereign/sidechain-health.node-test.js ../tests/sovereign/sidechain-witness.node-test.js" + "test:node": "node --test ../tests/contract/harness-bridge.contract.spec.js ../tests/contract/upstream_vectors/all_fixtures.test.js ../tests/sovereign/zone-keys.node-test.js ../tests/sovereign/g5-key-split.node-test.js ../tests/sovereign/nip98-host-port.node-test.js ../tests/sovereign/chain-info.node-test.js ../tests/sovereign/pod-sovereign-signer.node-test.js ../tests/sovereign/identity-port.node-test.js ../tests/sovereign/sidestr-spend-key.node-test.js ../tests/sovereign/chain-payments.node-test.js ../tests/sovereign/dispatch-as.node-test.js ../tests/sovereign/sidechain-health.node-test.js ../tests/sovereign/sidechain-witness.node-test.js ../tests/sovereign/role-secret.node-test.js" }, "jest": { "roots": [ diff --git a/management-api/routes/git-bridge.js b/management-api/routes/git-bridge.js index 46ec9da14..1e6e2e925 100644 --- a/management-api/routes/git-bridge.js +++ b/management-api/routes/git-bridge.js @@ -30,6 +30,7 @@ const { promisify } = require('util'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const agentIdentity = require('../lib/agent-identity'); const execFileAsync = promisify(execFile); const fsMkdir = promisify(fs.mkdir); @@ -223,19 +224,13 @@ async function emitNostrEvent(decision, logger) { }; // Signing requires a secret key — if not available, emit unsigned for relay - // bridges that accept them (e.g. the embedded relay). In production, the - // sovereign-bootstrap injects AGENTBOX_NSEC. - const nsec = process.env.AGENTBOX_NSEC; - if (nsec) { + // bridges that accept them (e.g. the embedded relay). Custody W2: the key + // comes from resolveDecisionSigner (AGENTBOX_NSEC_FILE, the bare variable only + // with role isolation off, else the sovereign identity file under the flag). + const signer = resolveDecisionSigner({ logger }); + if (signer) { try { - let sk; - if (nsec.startsWith('nsec1')) { - const decoded = nostrTools.nip19.decode(nsec); - sk = decoded.data; - } else { - sk = nsec; - } - const signedEvent = nostrTools.finalizeEvent(event, sk); + const signedEvent = await signer.sign(event, nostrTools); for (const relayUrl of NOSTR_RELAYS) { try { @@ -251,7 +246,52 @@ async function emitNostrEvent(decision, logger) { logger.error({ err: err.message }, 'git-bridge: Nostr event signing failed'); } } else { - logger.debug('git-bridge: AGENTBOX_NSEC not set, skipping Nostr event signing'); + logger.debug('git-bridge: no signing key (AGENTBOX_NSEC / sovereign identity), skipping Nostr event signing'); + } +} + +/** + * The signer for enrichment-decision events (custody W2). + * + * 1. AGENTBOX_NSEC through role-secret.js: AGENTBOX_NSEC_FILE wins; the bare + * variable is honoured only while [security].role_isolation is off. + * 2. Under the flag, identity.env no longer exports AGENTBOX_NSEC, so the same + * sovereign key is taken from the identity file (loadSovereignSigner, the + * ADR-2078 pods signer path), which verifies the key against its pubkey. + * + * Returns null when neither yields a key. Never logs a value. + * + * @param {object} [opts] + * @param {object} [opts.env=process.env] + * @param {object} [opts.logger] + * @param {Function} [opts.loadSovereignSigner] + * @returns {{source:string, sign(event:object, nostrTools:object):Promise}|null} + */ +function resolveDecisionSigner(opts = {}) { + const env = opts.env || process.env; + const log = opts.logger || { warn() {} }; + let nsec = ''; + try { + nsec = agentIdentity.readRoleSecret('AGENTBOX_NSEC', { env }); + } catch (err) { + log.warn({ err: err.message }, 'git-bridge: AGENTBOX_NSEC_FILE unreadable'); + } + if (nsec) { + return { + source: 'AGENTBOX_NSEC', + async sign(event, nostrTools) { + const sk = nsec.startsWith('nsec1') ? nostrTools.nip19.decode(nsec).data : nsec; + return nostrTools.finalizeEvent(event, sk); + }, + }; + } + if (!agentIdentity.roleIsolation(env)) return null; + try { + const sovereign = (opts.loadSovereignSigner || agentIdentity.loadSovereignSigner)({ env }); + return { source: 'sovereign-identity', sign: (event) => sovereign.sign(event) }; + } catch (err) { + log.warn({ err: err.message }, 'git-bridge: sovereign identity unavailable for signing'); + return null; } } @@ -760,3 +800,4 @@ async function gitBridgeRoutes(fastify, options) { } module.exports = gitBridgeRoutes; +module.exports.resolveDecisionSigner = resolveDecisionSigner; diff --git a/schema/agentbox.toml.schema.json b/schema/agentbox.toml.schema.json index ac5ab6756..0e2720f5b 100644 --- a/schema/agentbox.toml.schema.json +++ b/schema/agentbox.toml.schema.json @@ -1955,6 +1955,11 @@ "type": "boolean", "description": "Operator acknowledgement of residual risk from security exceptions." }, + "role_isolation": { + "type": "boolean", + "default": false, + "description": "ADR-2122 (custody X-1 step 1): run role programs under per-role service accounts (config/role-accounts.json) with their secrets in a root-owned /run/secrets//, the classified variables scrubbed from PID 1's environment, and devuser without the raw Docker socket. Boot-class: both supervisor configs ship in every image. Keep false until the role-isolation rehearsal passes." + }, "deepsec": { "type": "object", "additionalProperties": false, diff --git a/scripts/activation/role-isolation-rehearsal.host.sh b/scripts/activation/role-isolation-rehearsal.host.sh new file mode 100755 index 000000000..9ad791964 --- /dev/null +++ b/scripts/activation/role-isolation-rehearsal.host.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# X-1 step 1 boot rehearsal, HOST half (custody design §4, §8.5; workstream W6a). +# +# Run on the HOST shell (tmux tab 6) as root, against the running agentbox container. NEVER from +# inside the container: there it would reach the docker socket as devuser, which is the very +# bypass (C1) the rehearsal exists to prove closed. It refuses when it detects a container. +# +# It makes the probes that need root's view, which the container half (run as devuser) cannot: +# (a) each role secret under /run/secrets// is present, owned by the role, mode 0400, +# readable by the role uid (setpriv), refused to another role and to devuser; extra files +# in the dir (env-derived) obey the same; /run/secrets is a mount point; at-rest copies +# are root-owned 0400 in root 0700 directories +# (b) each role program's pid runs as the role uid (root reads /proc//status) +# (e) no process outside a variable's role holds a classified variable NAME in its environ +# (all uids, PID 1 included), and identity.env names none; names only, never values +# h-uid-collision getent passwd / group on the host for ids 960-979: any entry is a FAIL, +# because files on named volumes would take that host account's identity +# h-socket the host's /var/run/docker.sock mode and group, RECORDED for the owner +# (Q2 is host-side; not a gate) +# +# The in-container probe is a script embedded below, piped to `docker exec -u 0 … /bin/bash -s` +# with PATH reduced to /nix/store entries: root never executes a devuser-writable file. This +# file itself sits in the bind-mounted checkout, which devuser can write, so by default it +# refuses when scripts/activation differs from HEAD; review the diff, then --allow-dirty. +# +# Verdict and exit code as the container half: 0 PASS, 2 STAGED (flag off: every isolation row +# not_applicable; the socket row is recorded), 1 FAIL (or could not run). +# Receipt: docs/estate-closeout/x1-rehearsal-host-.json (schema x1-rehearsal.schema.json). +# +# --container NAME default agentbox +# --receipt-dir DIR default docs/estate-closeout of this checkout +# --allow-dirty run although scripts/activation differs from HEAD +# +# Test seams (tests/config/role-isolation-rehearsal.test.sh): HR_ASSUME_CONTAINER=1 (forces the +# in-container refusal; it cannot suppress it), HR_TEST_ROOT (skips the host, +# root and clean-tree guards; only for fakes), HR_DOCKER, HR_GETENT, HR_STAT, HR_HOST_SOCKET, +# HR_REGISTRY, HR_CONTAINER_REPO. + +# jq programs go through the jqx wrapper, which shellcheck does not recognise as jq: their $vars +# are jq's, not the shell's. +# shellcheck disable=SC2016 + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +DOCKER="${HR_DOCKER:-docker}" +GETENT="${HR_GETENT:-getent}" +STAT="${HR_STAT:-stat}" +HOST_SOCKET="${HR_HOST_SOCKET:-/var/run/docker.sock}" +CONTAINER_REPO="${HR_CONTAINER_REPO:-/home/devuser/workspace/project/agentbox}" +C=agentbox +RECEIPT_DIR="$REPO/docs/estate-closeout" +ALLOW_DIRTY=0 + +die() { echo "role-isolation-rehearsal.host: $*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --container) C="${2:?--container needs a name}"; shift ;; + --receipt-dir) RECEIPT_DIR="${2:?--receipt-dir needs a directory}"; shift ;; + --allow-dirty) ALLOW_DIRTY=1 ;; + -h|--help) sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; + *) die "unknown argument '$1'" ;; + esac + shift +done + +in_container() { + # HR_ASSUME_CONTAINER=1 can only add a refusal, never remove one: the test sets it so the + # refusal is exercised on a CI runner that is a VM, not a container. + [ "${HR_ASSUME_CONTAINER:-}" = 1 ] && return 0 + [ -e /.dockerenv ] || [ -e /run/.containerenv ] && return 0 + grep -qE '(docker|containerd|kubepods|libpod)' /proc/1/cgroup 2>/dev/null +} +if [ -z "${HR_TEST_ROOT:-}" ]; then + if in_container; then die "this is the host half: run it from the host shell, never from inside the container"; fi + [ "$(id -u)" = 0 ] || die "run as root on the host" + if [ "$ALLOW_DIRTY" = 0 ] && ! git -C "$REPO" diff --quiet HEAD -- scripts/activation 2>/dev/null; then + die "scripts/activation differs from HEAD; review 'git -C $REPO diff HEAD -- scripts/activation', then pass --allow-dirty" + fi +fi + +# JSON assembly: the host's jq when present, otherwise the image's jq run as devuser (data only). +if command -v jq >/dev/null 2>&1; then JQ=(jq); else JQ=("$DOCKER" exec -i -u 1000 "$C" jq); fi +jqx() { "${JQ[@]}" "$@"; } + +[ "$("$DOCKER" inspect -f '{{.State.Running}}' "$C" 2>/dev/null)" = true ] || die "container '$C' is not running" +IMAGE_ID="$("$DOCKER" inspect -f '{{.Image}}' "$C" 2>/dev/null)"; IMAGE_ID="${IMAGE_ID:-unknown}" +NOW="$(date -u +%s)"; STARTED="$(date -u -d "@$NOW" +%Y-%m-%dT%H:%M:%SZ)" + +# Root PATH for the probe: store paths only (never /usr/local/bin or a workspace path). +PROBE_PATH="$("$DOCKER" inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$C" 2>/dev/null | sed -n 's/^PATH=//p' | tr ':' '\n' | grep '^/nix/store/' | paste -sd: -)" +[ -n "$PROBE_PATH" ] || die "could not read a /nix/store PATH from the image config" + +# Manifest and role table, read from the container (image files first). +MANIFEST_TEXT="$("$DOCKER" exec -u 0 "$C" cat /etc/agentbox.toml 2>/dev/null)" || die "cannot read /etc/agentbox.toml in '$C'" +FLAG_RAW="$(awk '$0=="[security]"{f=1;next} /^\[/{f=0} f && /^role_isolation[[:space:]]*=/ {sub(/^[^=]*=[[:space:]]*/,""); sub(/[[:space:]]*(#.*)?$/,""); print; exit}' <<<"$MANIFEST_TEXT")" +if [ -z "$FLAG_RAW" ]; then FLAG=false; FLAG_SOURCE=absent-default-false +else case "$FLAG_RAW" in 1|true|TRUE|True|yes|on) FLAG=true ;; *) FLAG=false ;; esac; FLAG_SOURCE=manifest; fi + +# One reader of W1's table and plan: the container half's --print-registry normalises +# /etc/agentbox/role-accounts.json + role-secrets.tsv into the rows both halves check. +if [ -n "${HR_REGISTRY:-}" ]; then REGISTRY="$HR_REGISTRY" +else REGISTRY="$("$DOCKER" exec -u 1000 "$C" bash "$CONTAINER_REPO/scripts/activation/role-isolation-rehearsal.sh" --print-registry)" || die "could not obtain the role table (the image needs /etc/agentbox/role-accounts.json and role-secrets.tsv)"; fi +REGISTRY="$(jqx -c . <<<"$REGISTRY")" || die "the role table is not JSON" + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/x1-rehearsal-host.XXXXXX")" || die "mktemp failed" +trap 'rm -rf "$WORK"' EXIT +ROWS="$WORK/rows.jsonl"; : >"$ROWS" + +echo "X-1 role-isolation rehearsal, host half — container $C, image $IMAGE_ID, flag role_isolation=$FLAG ($FLAG_SOURCE)" + +# ── the in-container root probe (runs as root inside $C; emits JSONL rows) ───────────────── +read -r -d '' PROBE <<'PROBE_EOF' +set -uo pipefail +reg="$RH_REGISTRY_JSON" +row() { jq -nc --arg c "$1" --arg t "$2" --arg e "$3" --arg o "$4" --argjson ok "$([ "$5" = 1 ] && echo true || echo false)" --argjson ev "${6:-null}" '{check:$c,target:$t,expected:$e,observed:$o,ok:$ok,evidence:($ev // {})}'; } +as_uid() { setpriv --reuid="$1" --regid="$1" --clear-groups head -c0 -- "$2" >/dev/null 2>&1; } +other_role_uid() { jq -r --argjson u "$1" '[.roles[] | select(.uid != $u and ((.files | length) > 0 or (.programs | length) > 0)) | .uid][0] // empty' <<<"$reg"; } + +# (a) +if mountpoint -q /run/secrets; then row a "/run/secrets (root view)" "a mount point" "mount point" 1 '{"path":"/run/secrets","mount_point":true}' +else row a "/run/secrets (root view)" "a mount point" "not a mount point" 0 '{"path":"/run/secrets","mount_point":false}'; fi +while IFS=$'\t' read -r role uid; do + d="/run/secrets/$role"; other="$(other_role_uid "$uid")" + if [ ! -d "$d" ]; then row a "$d (root view)" "present" absent 0 "$(jq -nc --arg p "$d" '{path:$p,present:false}')"; continue; fi + want="$(jq -r --arg r "$role" '.roles[] | select(.name == $r) | .files[]' <<<"$reg")" + have="$(ls -A "$d")" + for f in $(printf '%s\n%s\n' "$want" "$have" | sort -u); do + p="$d/$f" + if [ ! -e "$p" ]; then row a "$p (root view)" "present, $role 0400" absent 0 "$(jq -nc --arg p "$p" '{path:$p,present:false}')"; continue; fi + read -r fu fm <<<"$(stat -c '%u %a' "$p")" + r_role=no; as_uid "$uid" "$p" && r_role=yes + r_dev=no; as_uid 1000 "$p" && r_dev=yes + r_oth=n/a; [ -n "$other" ] && { r_oth=no; as_uid "$other" "$p" && r_oth=yes; } + okv=0; [ "$fu" = "$uid" ] && [ "$fm" = 400 ] && [ $r_role = yes ] && [ $r_dev = no ] && [ "$r_oth" != yes ] && okv=1 + row a "$p (root view)" "owner $role 0400; role reads; devuser and another role refused" "owner $fu mode $fm; role read=$r_role devuser read=$r_dev other-role read=$r_oth" $okv \ + "$(jq -nc --arg p "$p" --argjson u "$fu" --arg m "$fm" --arg rr "$r_role" --arg rd "$r_dev" --arg ro "$r_oth" '{path:$p,uid:$u,mode:$m,role_read:$rr,devuser_read:$rd,other_role_read:$ro}')" + done +done < <(jq -r '.roles[] | select((.files | length) > 0 or (.programs | length) > 0) | [.name, .uid] | @tsv' <<<"$reg") +for p in $(jq -r '[.roles[].at_rest[]] | unique[]' <<<"$reg"); do + [ -e "$p" ] || { row a "$p (root view)" "root 0400 or absent" absent 1 "$(jq -nc --arg p "$p" '{path:$p,present:false}')"; continue; } + read -r fu fm <<<"$(stat -c '%u %a' "$p")" + row a "$p (root view)" "root 0400 or absent" "owner $fu mode $fm" "$([ "$fu" = 0 ] && [ "$fm" = 400 ] && echo 1 || echo 0)" "$(jq -nc --arg p "$p" --argjson u "$fu" --arg m "$fm" '{path:$p,uid:$u,mode:$m}')" +done +for p in $(jq -r '[.roles[].at_rest_dirs[]] | unique[]' <<<"$reg"); do + [ -d "$p" ] || continue + read -r fu fm <<<"$(stat -c '%u %a' "$p")" + row a "$p/ (root view)" "root 0700" "owner $fu mode $fm" "$([ "$fu" = 0 ] && [ "$fm" = 700 ] && echo 1 || echo 0)" "$(jq -nc --arg p "$p" --argjson u "$fu" --arg m "$fm" '{path:$p,uid:$u,mode:$m}')" +done + +# (b) +conf=/etc/supervisord.conf; [ "$FLAG" = true ] && conf=/etc/supervisord.roles.conf +status="$(supervisorctl -c "$conf" status 2>/dev/null)" +while IFS=$'\t' read -r role uid prog; do + pid="$(awk -v p="$prog" '$1 == p && $2 == "RUNNING" {sub(/,/,"",$4); print $4; exit}' <<<"$status")" + if [ -z "$pid" ]; then row b "$prog (root view)" "RUNNING as $role ($uid)" "not RUNNING" 0 "$(jq -nc --arg p "$prog" '{program:$p}')"; continue; fi + read -r ru eu <<<"$(awk '/^Uid:/ {print $2, $3}' "/proc/$pid/status")" + row b "$prog (root view)" "RUNNING as $role ($uid)" "pid $pid uid $ru/$eu" "$([ "$ru" = "$uid" ] && [ "$eu" = "$uid" ] && echo 1 || echo 0)" "$(jq -nc --arg p "$prog" --argjson pid "$pid" --argjson u "$ru" '{program:$p,pid:$pid,uid:$u}')" +done < <(jq -r '.roles[] | .name as $n | .uid as $u | .programs[] | [$n, $u, .] | @tsv' <<<"$reg") + +# (e) names only: environ is split on NUL and cut at '=' before anything is compared. +classified="$(jq -r '(.roles[] | .uid as $u | .env[] | "\(.) \($u)"), (.classified_root_env // [] | .[] | "\(.name) 0")' <<<"$reg")" +names="$(awk '{print $1}' <<<"$classified" | sort -u | paste -sd'|' -)" +hits="[]"; n=0 +for pd in /proc/[0-9]*; do + pid="${pd##*/}"; u="$(awk '/^Uid:/ {print $2; exit}' "$pd/status" 2>/dev/null)"; [ -n "$u" ] || continue + n=$((n + 1)) + while IFS= read -r name; do + [ -n "$name" ] || continue + holder="$(awk -v n="$name" '$1 == n {print $2; exit}' <<<"$classified")" + if [ "$pid" = 1 ] || [ "$u" != "$holder" ]; then + hits="$(jq -c --argjson pid "$pid" --argjson u "$u" --arg n "$name" '. + [{pid:$pid,uid:$u,name:$n}]' <<<"$hits")" + fi + done < <(tr '\0' '\n' <"$pd/environ" 2>/dev/null | cut -d= -f1 | grep -xE "$names") +done +row e "all-environ (root view)" "no classified name outside its role, none in PID 1 ($n processes)" "$(jq length <<<"$hits") hits" "$([ "$hits" = "[]" ] && echo 1 || echo 0)" "$(jq -nc --argjson h "$hits" --argjson n "$n" '{processes:$n,hits:$h}')" +f=/run/agentbox/identity.env +if [ -e "$f" ]; then + c="$(cut -d= -f1 "$f" | sed 's/^export[[:space:]]*//' | grep -cxE "$names")" + row e "$f (root view)" "no classified name" "$c lines name a role secret" "$([ "$c" = 0 ] && echo 1 || echo 0)" "$(jq -nc --argjson c "$c" '{path:"/run/agentbox/identity.env",lines:$c}')" +else row e "$f (root view)" "no classified name" absent 1 '{"path":"/run/agentbox/identity.env","present":false}'; fi +PROBE_EOF + +if ! "$DOCKER" exec -i -u 0 -e "PATH=$PROBE_PATH" -e "RH_REGISTRY_JSON=$REGISTRY" -e "FLAG=$FLAG" "$C" /bin/bash -s <<<"$PROBE" >"$WORK/probe.jsonl" 2>"$WORK/probe.err"; then + jqx -nc --arg e "$(tail -c 300 "$WORK/probe.err" | tr -d '\0')" '{check:"h-probe",target:"in-container root probe",expected:"ran",observed:("failed: " + $e),ok:false,evidence:{}}' >>"$ROWS" +fi +cat "$WORK/probe.jsonl" >>"$ROWS" +[ -s "$WORK/probe.jsonl" ] || jqx -nc '{check:"h-probe",target:"in-container root probe",expected:"rows",observed:"no rows",ok:false,evidence:{}}' >>"$ROWS" + +# ── host-local checks ────────────────────────────────────────────────────────────────────── +ACCOUNTS="[]" +for id in $(seq 960 979); do + for db in passwd group; do + line="$("$GETENT" "$db" "$id" 2>/dev/null | head -1)" + [ -n "$line" ] && ACCOUNTS="$(jqx -c --arg db "$db" --argjson id "$id" --arg n "${line%%:*}" '. + [{db:$db,id:$id,name:$n}]' <<<"$ACCOUNTS")" + done +done +NCOLL="$(jqx length <<<"$ACCOUNTS")" +jqx -nc --argjson h "$ACCOUNTS" --argjson n "$NCOLL" '{check:"h-uid-collision",target:"host getent passwd/group 960-979",expected:"no host account or group in the role range",observed:"\($n) entries",ok:($n == 0),evidence:{entries:$h}}' >>"$ROWS" + +S="$("$STAT" -c '%u %g %a %d' "$HOST_SOCKET" 2>/dev/null)" +read -r SU SG SM _ <<<"$S" +jqx -nc --arg s "$S" --arg u "${SU:-}" --arg g "${SG:-}" --arg m "${SM:-}" '{check:"h-socket",target:"/var/run/docker.sock (host)",expected:"recorded for the owner (Q2)", + observed:(if $s == "" then "absent" else "uid \($u) gid \($g) mode \($m)" + (if ($m | tostring | .[-1:]) != "0" then " (world-accessible)" else "" end) end),ok:true,recorded:true, + evidence:{path:"/var/run/docker.sock",uid:$u,gid:$g,mode:$m}}' >>"$ROWS" + +# ── verdict and receipt ─────────────────────────────────────────────────────────────────── +FINISHED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +CHECKS="$(jqx -sc --argjson flag "$FLAG" 'map( + if .recorded then . + {status:"recorded"} + elif $flag then . + {status:(if .ok then "pass" else "fail" end)} + else . + {status:"not_applicable", reason:"flag off", would_pass:.ok} end | del(.ok, .recorded))' "$ROWS")" +FAILS="$(jqx '[.[] | select(.status == "fail")] | length' <<<"$CHECKS")" +if [ "$FLAG" != true ]; then VERDICT=STAGED; CODE=2 +elif [ "$FAILS" -gt 0 ]; then VERDICT=FAIL; CODE=1 +else VERDICT=PASS; CODE=0; fi + +COMMIT="$(git -C "$REPO" rev-parse HEAD 2>/dev/null || echo unknown)" +DIRTY=false; git -C "$REPO" diff --quiet HEAD -- scripts/activation 2>/dev/null || DIRTY=true +mkdir -p "$RECEIPT_DIR" || die "cannot create $RECEIPT_DIR" +RECEIPT="$RECEIPT_DIR/x1-rehearsal-host-$(date -u -d "$FINISHED" +%Y%m%dT%H%M%SZ).json" +jqx -n --arg verdict "$VERDICT" --argjson code "$CODE" --argjson flag "$FLAG" --arg fsrc "$FLAG_SOURCE" \ + --arg image "$IMAGE_ID" --arg c "$C" --arg commit "$COMMIT" --argjson dirty "$DIRTY" --arg s "$STARTED" --arg f "$FINISHED" \ + --arg op "$(id -un 2>/dev/null || echo unknown)" --argjson opuid "$(id -u)" --argjson reg "$REGISTRY" --argjson acc "$ACCOUNTS" \ + --argjson checks "$CHECKS" '{ + schema: "agentbox/x1-rehearsal@1", + half: "host", + verdict: $verdict, exit_code: $code, + flag: {key: "[security].role_isolation", value: $flag, source: $fsrc, manifest: "/etc/agentbox.toml"}, + image: {build_id: $image, container: $c}, + commit: $commit, rehearsal_dirty: $dirty, + started_at: $s, finished_at: $f, + operator: {user: $op, uid: $opuid}, + uid_table: {source: $reg.source, roles: [$reg.roles[] | {role: .name, uid, gid, programs, files: [.files[] as $x | "/run/secrets/\(.name)/\($x)"], classified_env: .env} + (if .deferred then {deferred} else {} end)], host_accounts: $acc}, + checks: $checks, + summary: { + pass: ([$checks[] | select(.status == "pass")] | length), + fail: ([$checks[] | select(.status == "fail")] | length), + not_applicable: ([$checks[] | select(.status == "not_applicable")] | length), + recorded: ([$checks[] | select(.status == "recorded")] | length), + would_fail: ([$checks[] | select(.would_pass == false)] | length), + failed_checks: ([$checks[] | select(.status == "fail" or .would_pass == false) | .check] | unique) + }, + legacy_copies: ([$reg.roles[].legacy[]] | unique), + degraded: [], + not_covered: [ + "devuser-side probes, (c) signing, (d) producer, (f) group and socket reachability (role-isolation-rehearsal.sh, in the container as devuser)", + "idempotence and rollback across reboots (owner-run; needs two boots)" + ] + }' >"$RECEIPT" || die "could not write $RECEIPT" + +jqx -r '.[] | " \(if .status == "pass" then "ok " elif .status == "fail" then "FAIL" elif .status == "recorded" then "rec " else (if .would_pass then "n/a+" else "n/a-" end) end) (\(.check)) \(.target) — \(.observed)"' <<<"$CHECKS" +echo +jqx -r '"verdict \(.verdict) (exit \(.exit_code)): \(.summary.pass) pass, \(.summary.fail) fail, \(.summary.not_applicable) not applicable, \(.summary.recorded) recorded"' <"$RECEIPT" +echo "receipt ${RECEIPT#"$REPO"/}" +exit "$CODE" diff --git a/scripts/activation/role-isolation-rehearsal.sh b/scripts/activation/role-isolation-rehearsal.sh new file mode 100755 index 000000000..033f7e887 --- /dev/null +++ b/scripts/activation/role-isolation-rehearsal.sh @@ -0,0 +1,661 @@ +#!/usr/bin/env bash +# X-1 step 1 boot rehearsal, container half (custody design §4; workstream W6a). +# +# Decides whether `[security].role_isolation` may stay on. Runs INSIDE the container AS +# DEVUSER and probes from devuser's side of each boundary. The root's-eye probes (role-uid +# reads, other processes' environ, host socket mode, host uid collisions) are the host half: +# scripts/activation/role-isolation-rehearsal.host.sh, run from the host shell. +# +# Checks (each row of the receipt carries one target): +# (a) every role secret under /run/secrets// and every at-rest copy on the volumes is +# refused to devuser with EACCES; /run/secrets is a root-owned 0711 mount point; each +# /run/secrets// is 0500 and owned by the role uid +# (b) every role program is RUNNING under supervisor with real and effective uid equal to the +# role uid, and its /proc//environ is unreadable to devuser +# (c) signing works through the identity port: a pods NIP-98 header and a forum event made by +# the port verify with the estate's own verifier (NostrBridge.verifyNip98 / nostr-tools), +# a NIP-42 AUTH is accepted by the loopback relay, and dm_unwrap / a foreign URL / a +# generic sign op are refused, with one sign receipt line per call +# (d) each chain enabled in the manifest ([sidechain], [sidechain.]) is baked into the +# image's plan and vice versa; its producer runs as its role uid and has a block younger +# than 2 x its interval (--wait: otherwise poll until the tip advances, at most 2 x interval) +# (e) no role secret is ambient: classified by the role inventory's VARIABLE NAMES and flag +# names, never by value, across every process's argv, the environ of every devuser +# process, the supervisor configs' environment= lines, identity.env and /var/log +# (f) devuser is not in group 0, cannot drive the docker socket, cannot sudo, and no role +# gid equals the socket's group +# +# Verdict and exit code: +# 0 PASS flag on, every row passed +# 2 STAGED flag off (absent = false): every row is "not_applicable: flag off"; the receipt +# still carries what was observed and whether the row would have passed +# 1 FAIL flag on and a row failed, OR the rehearsal could not run. A check that is +# required but could not be made (missing tool, socket, program) is a FAIL. +# +# Receipt: docs/estate-closeout/x1-rehearsal-.json, schema +# docs/estate-closeout/schema/x1-rehearsal.schema.json. It holds names, paths, modes, uids, +# pids, counts and public keys only. No secret value is read into the receipt or printed: +# environ files are parsed for names, log files are counted with grep -c, and secret files +# are only opened, never read. +# +# The role table: /etc/agentbox/role-accounts.json (W1's agentbox.role-accounts/1, baked from +# config/role-accounts.json) and its resolved plan /etc/agentbox/role-secrets.tsv. Both are +# required; there is no derived numbering (uid 965 is the host docker group and is reserved). +# +# Identity-port client contract for (c), as W3 implements it (custody/w3-identity-port; +# services/nostr-pod-bridge/src/identity_port). Socket /run/secrets/ab-identity-port/identity.sock (0660, +# authorised by SO_PEERCRED uid against config/custody/identity-port-acl.json): +# nostr-pod-bridge sign-request JSON params on stdin, JSON on stdout, exit 0 +# pubkey {key} -> {"pubkey": "", "npub", "did"} +# nip98 {key, method, url, payload_sha256} -> {"header": "Nostr ", "event_id"} +# forum_event {key, kind, content, dry_run} -> {"event": {...signed...}} +# nip42_auth {key, relay, challenge} -> {"event": {...kind 22242...}} +# a refusal exits 1 with {"refused": {"op": "", "reason": ""}}; no port exits 2 +# with {"unavailable": {...}}. Each decision appends one line under +# /var/lib/agentbox/events/sign/sign-.jsonl. +# +# --print-registry print the role table as JSON and exit (the host half uses this) +# --wait (d): poll a stale tip for up to 2 x interval instead of failing at once +# --receipt-dir DIR where the receipt goes (default: docs/estate-closeout of this checkout) +# +# Test seams (tests/config/role-isolation-rehearsal.test.sh): RH_ROOT (prefix for every +# filesystem path), RH_MANIFEST, RH_REGISTRY, RH_SUPERVISORCTL, RH_DOCKER, RH_SUDO, RH_GROUPS, +# RH_DEVUSER_UID, RH_STAT, RH_SIGN_CLIENT, RH_NIP98_VERIFIER, RH_RELAY_AUTH, RH_CURL, RH_NOW, +# RH_EXPECT_CORE_PUBKEY, RH_EXPECT_JJ_PUBKEY, RH_IMAGE_ID, RH_RELAY_URL, RH_IDENTITY_SOCK. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +R="${RH_ROOT:-}" +MANIFEST="${RH_MANIFEST:-$R/etc/agentbox.toml}" +SUPERVISORCTL="${RH_SUPERVISORCTL:-supervisorctl}" +DOCKER="${RH_DOCKER:-docker}" +SUDO="${RH_SUDO:-sudo}" +STAT="${RH_STAT:-stat}" +CURL="${RH_CURL:-curl}" +SIGN_CLIENT="${RH_SIGN_CLIENT:-nostr-pod-bridge sign-request}" +RELAY_URL="${RH_RELAY_URL:-ws://127.0.0.1:7777}" +DEV_UID="${RH_DEVUSER_UID:-$(id -u)}" +EXPECT_CORE="${RH_EXPECT_CORE_PUBKEY-${AGENTBOX_X_ONLY_PUBKEY_HEX:-}}" +EXPECT_JJ="${RH_EXPECT_JJ_PUBKEY-${JUNKIEJARVIS_PUBKEY:-}}" +IMAGE_ID="${RH_IMAGE_ID-${AGENTBOX_IMAGE_HASH:-}}" +RECEIPT_DIR="$REPO/docs/estate-closeout" +WAIT=0 +MODE=run + +while [ $# -gt 0 ]; do + case "$1" in + --print-registry) MODE=registry ;; + --wait) WAIT=1 ;; + --receipt-dir) RECEIPT_DIR="${2:?--receipt-dir needs a directory}"; shift ;; + -h|--help) sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; + *) echo "role-isolation-rehearsal: unknown argument '$1'" >&2; exit 1 ;; + esac + shift +done + +die() { echo "role-isolation-rehearsal: $*" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || die "jq is required" +command -v node >/dev/null 2>&1 || die "node is required" + +# The container half is devuser's view. Run as root it would see through every boundary and +# pass vacuously; outside the container it would probe the host. +if [ -z "$R" ]; then + [ "$(id -u)" != 0 ] || die "run this as devuser; the root view is the host half's job" + [ -e /.dockerenv ] || die "run this inside the agentbox container (the host half is role-isolation-rehearsal.host.sh)" +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/x1-rehearsal.XXXXXX")" || die "mktemp failed" +trap 'rm -rf "$WORK"' EXIT +ROWS="$WORK/rows.jsonl"; : >"$ROWS" + +# ── manifest ────────────────────────────────────────────────────────────────────────────── +# Same anchored-awk reader as config/entrypoint-unified.sh (_ab_toml_val). +toml_val() { #
+ awk -v sec="[$1]" -v key="$2" ' + $0==sec {f=1;next} /^\[/{f=0} + f && index($0,key)==1 && $0 ~ ("^" key "[[:space:]]*=") { + sub(/^[^=]*=[[:space:]]*/,""); sub(/[[:space:]]*(#.*)?$/,""); gsub(/"/,""); + print; exit }' "$MANIFEST" +} +toml_bool() { case "$(toml_val "$1" "$2")" in 1|true|TRUE|True|yes|on) echo true ;; *) echo false ;; esac; } +toml_int() { local v; v="$(toml_val "$1" "$2")"; case "$v" in ''|*[!0-9]*) echo "$3" ;; *) echo "$v" ;; esac; } + +[ -r "$MANIFEST" ] || die "manifest $MANIFEST is unreadable; the flag cannot be known" +FLAG_RAW="$(toml_val security role_isolation)" +if [ -z "$FLAG_RAW" ]; then FLAG=false; FLAG_SOURCE="absent-default-false" +else FLAG="$(toml_bool security role_isolation)"; FLAG_SOURCE="manifest"; fi + +# ── role table ──────────────────────────────────────────────────────────────────────────── +# W1's table is canonical (ADR-2122): /etc/agentbox/role-accounts.json (agentbox.role-accounts/1) +# and the plan `agentbox-manifest role-accounts isolate` resolved from it, +# /etc/agentbox/role-secrets.tsv. There is no fallback numbering: an image without both cannot +# be rehearsed, and that is a FAIL. normalise_registry folds the two into the rows the checks +# read: per role {name, uid, gid, programs, files, env, at_rest, at_rest_dirs, legacy} plus +# {chain, port, interval, enabled, baked} for producers: enabled is the manifest's +# [sidechain] / [sidechain.] tables, baked is whether the plan delivers the role anything. A role with secrets but no plan row is not +# delivered on this image (its chain is off): it gets no programs and no files. +ROLE_TABLE="$R/etc/agentbox/role-accounts.json" +ROLE_PLAN="$R/etc/agentbox/role-secrets.tsv" +# W2's environment classes (ADR-2122 §3a): the ROLE class names every secret variable and its +# holder, including the ones W1's plan does not deliver (w2_only). (e) classifies by these names. +ENV_CLASSES="${RH_ENV_CLASSES:-$REPO/config/custody/env-classes.json}" +# Copies the table does not deliver but that hold ab-identity's keys today; (a) probes them. +# A plan with the custody registry (atrest/atrestdir rows, W2b) supplies the volume copies, +# their dirs and their legacy twins itself; this constant then adds only the flag-off tmpfs +# copy (/run/secrets/nostr.key, which must be absent under the flag). +IDENTITY_AT_REST='{"at_rest":["/var/lib/agentbox/identities/agentbox-core.json","/run/secrets/nostr.key"],"at_rest_dirs":["/var/lib/agentbox/identities"],"legacy":["/home/devuser/workspace/.agentbox/zone-keys.json"]}' + +normalise_registry() { # + local chains c port interval on base + chains="$(jq -r '.roles[].name | select(startswith("ab-sidestr-")) | sub("^ab-sidestr-"; "")' "$1")" + local cfg='{}' + # The manifest decides which chains are on: [sidechain] is dreamlab's table and its + # `enabled` dominates; every other chain needs its own [sidechain.].enabled as well + # (agentbox.toml, "[sidechain].enabled above dominates"). A missing key is off. + base="$(toml_bool sidechain enabled)" + while IFS= read -r c; do + [ -n "$c" ] || continue + if [ "$c" = dreamlab ]; then port="$(toml_int sidechain port 3450)"; interval="$(toml_int sidechain interval 600)"; on="$base" + else port="$(toml_int "sidechain.$c" port 3450)"; interval="$(toml_int "sidechain.$c" interval 600)" + on=false; [ "$base" = true ] && on="$(toml_bool "sidechain.$c" enabled)"; fi + cfg="$(jq -c --arg c "$c" --argjson p "$port" --argjson i "$interval" --argjson on "$on" '.[$c] = {port: $p, interval: $i, enabled: $on}' <<<"$cfg")" + done <<<"$chains" + jq -c --rawfile plan "$2" --arg src "$1 + $2 + ${ENV_CLASSES#"$REPO/"}" --argjson chains "$cfg" --argjson ident "$IDENTITY_AT_REST" \ + --slurpfile classes "$ENV_CLASSES" ' + ($plan | split("\n") | map(select(length > 0 and (startswith("#") | not)) | split("\t"))) as $rows + | ([$rows[] | select(.[0] == "atrestdir") | .[1]]) as $ardirs + | (($ardirs | length) > 0) as $registry + | (.reserved_ids // {} | keys | map(tonumber)) as $reserved + | (reduce .roles[] as $r ({}; .[$r.name] = $r.uid)) as $roles_uid + | if ([.roles[].uid] | any(. as $u | $reserved | index($u))) then error("a role uid is reserved (reserved_ids)") else . end + | ($classes[0].classes.ROLE | to_entries | map({name: .key, role: .value.role})) as $role_env + | .secrets_root as $sr + | ([.groups // [] | .[] | .owner as $o | {path: ($sr + "/" + .name), group: .name, gid, + owner: $o}]) as $sockdirs + | {source: $src, schema: .schema, reserved_ids: ($reserved), + classified_root_env: [$role_env[] | select(.role == "root") | {name, holder: "root"}], + socket_dirs: [$sockdirs[] | . as $d | . + {owner_uid: ($roles_uid[$d.owner] // null)}], + roles: [.roles[] | .name as $n + | ([$rows[] | select((.[0] == "file" or .[0] == "env") and .[1] == $n)]) as $mine + | ((.secrets | length) == 0 or ($mine | length) > 0) as $on + | ([$rows[] | select(.[0] == "atrest" and .[1] == $n)]) as $ar + | {name: $n, uid, gid: .uid, + programs: (if $on then .programs else [] end), + files: [$mine[] | .[2]], + env: ([.secrets[] | .from_env // empty] + [$role_env[] | select(.role == $n) | .name] | unique), + at_rest: (if $registry + then ([$mine[] | select(.[0] == "file") | .[3]] + [$ar[] | .[2]] + + (if $n == "ab-identity" then ["/run/secrets/nostr.key"] else [] end) | unique) + else ([$mine[] | select(.[0] == "file") | .[3]] + (if $n == "ab-identity" then $ident.at_rest else [] end)) end), + at_rest_dirs: (if $registry + then ([$ar[] | .[2] | sub("/[^/]*$"; "") | select(. as $d | $ardirs | index($d))] | unique) + else (([$mine[] | select(.[0] == "file") | .[3] | select(startswith("/var/lib/agentbox/")) | sub("/[^/]*$"; "")] | unique) + + (if $n == "ab-identity" then $ident.at_rest_dirs else [] end)) end), + legacy: (if $registry then ([$ar[] | .[3] | select(. != "-")] | unique) + else (if $n == "ab-identity" then $ident.legacy else [] end) end)} + + (if ($n | startswith("ab-sidestr-")) then ($n | sub("^ab-sidestr-"; "")) as $c + | {chain: $c, baked: $on} + ($chains[$c] // {port: 3450, interval: 600, enabled: false}) else {} end) + + (if (.programs | length) == 0 and (.secrets | length) == 0 then {deferred: .purpose} else {} end)]}' "$1" +} + +if [ -n "${RH_REGISTRY:-}" ]; then REGISTRY="$(jq -c . "$RH_REGISTRY")" || die "RH_REGISTRY is not JSON" +else + [ -r "$ROLE_TABLE" ] || die "${ROLE_TABLE#"$R"} is missing: this image predates the role accounts (ADR-2122) and cannot be rehearsed" + [ -r "$ROLE_PLAN" ] || die "${ROLE_PLAN#"$R"} is missing: the image ships the role table without its resolved plan" + [ "$(jq -r .schema "$ROLE_TABLE" 2>/dev/null)" = agentbox.role-accounts/1 ] || die "${ROLE_TABLE#"$R"} is not agentbox.role-accounts/1" + jq -e '.classes.ROLE | type == "object"' "$ENV_CLASSES" >/dev/null 2>&1 || die "$ENV_CLASSES has no ROLE class (custody W2)" + REGISTRY="$(normalise_registry "$ROLE_TABLE" "$ROLE_PLAN")" || die "could not read the role table and plan" +fi + +if [ "$MODE" = registry ]; then printf '%s\n' "$REGISTRY"; exit 0; fi + +# Every classified variable name, each with the role allowed to hold it. +CLASSIFIED="$(jq -r '(.roles[] | .name as $r | .env[] | "\(.) \($r)"), (.classified_root_env // [] | .[] | "\(.name) root")' <<<"$REGISTRY")" +CLASS_NAMES="$(awk '{print $1}' <<<"$CLASSIFIED" | sort -u | paste -sd'|' -)" +SECRET_FLAGS='--nsec|--privkey|--private-key|--secret-key|--sk' +ROLE_GIDS="$(jq -r '.roles[].gid' <<<"$REGISTRY" | sort -u | paste -sd' ' -)" + +# ── row recording ───────────────────────────────────────────────────────────────────────── +# row [evidence-json] +row() { + local ev="${6:-}"; [ -n "$ev" ] || ev='{}' + jq -nc --arg c "$1" --arg t "$2" --arg e "$3" --arg o "$4" --argjson ok "$([ "$5" = 1 ] && echo true || echo false)" \ + --argjson ev "$ev" '{check:$c,target:$t,expected:$e,observed:$o,ok:$ok,evidence:$ev}' >>"$ROWS" +} + +# st -> "uid gid octal-mode device" (empty when the path cannot be stat'ed) +st() { "$STAT" -c '%u %g %a %d' "$1" 2>/dev/null; } + +# try_open -> ok | EACCES | ENOENT | other:. Opens for reading, reads nothing. +try_open() { + local err + if err="$( { : <"$1"; } 2>&1 )"; then echo ok; return; fi + case "$err" in *"Permission denied"*) echo EACCES ;; *"No such file"*) echo ENOENT ;; *) echo "other:${err##*: }" ;; esac +} +try_list() { + local err + if err="$(ls -- "$1" 2>&1 >/dev/null)"; then echo ok; return; fi + case "$err" in *"Permission denied"*) echo EACCES ;; *"No such file"*) echo ENOENT ;; *) echo other ;; esac +} + +# proc_uids -> "real effective saved fs" ("" if gone) +proc_uids() { awk '/^Uid:/ {print $2, $3, $4, $5; exit}' "$R/proc/$1/status" 2>/dev/null; } + +# ── supervisor view ─────────────────────────────────────────────────────────────────────── +ROLES_CONF="$R/etc/supervisord.roles.conf" +PLAIN_CONF="$R/etc/supervisord.conf" +if [ "$FLAG" = true ] || [ -r "$ROLES_CONF" ]; then ACTIVE_CONF="$ROLES_CONF"; else ACTIVE_CONF="$PLAIN_CONF"; fi +SUP_STATUS="$("$SUPERVISORCTL" -c "${ACTIVE_CONF#"$R"}" status 2>/dev/null)"; SUP_RC=$? +# supervisorctl status exits 3 when any program is not RUNNING; that is not an error here. +[ -n "$SUP_STATUS" ] || SUP_RC=99 +sup_line() { awk -v p="$1" '$1 == p {print; exit}' <<<"$SUP_STATUS"; } +sup_state() { awk '{print $2}' <<<"$(sup_line "$1")"; } +sup_pid() { sed -n 's/.* pid \([0-9]*\),.*/\1/p' <<<"$(sup_line "$1")"; } + +echo "X-1 role-isolation rehearsal, container half — flag role_isolation=$FLAG ($FLAG_SOURCE)" + +# ── (a) only the role can read its secret ───────────────────────────────────────────────── +check_a() { + local s uid gid mode dev rdev role ruid f p r d + local obs mp=false ok=0 + s="$(st "$R/run/secrets")"; read -r uid _ mode dev <<<"$s" + rdev="$(st "$R/run" | awk '{print $4}')" + if [ -z "$s" ]; then obs=absent + else + [ -n "$rdev" ] && [ "$dev" != "$rdev" ] && mp=true + obs="uid $uid mode $mode, $([ "$mp" = true ] && echo "own mount" || echo "same filesystem as /run (renamable by /run's owner)")" + [ "$uid" = 0 ] && [ "$mode" = 711 ] && [ "$mp" = true ] && ok=1 + fi + row a /run/secrets "root-owned 0711 mount point" "$obs" "$ok" \ + "$(jq -nc --arg u "${uid:-}" --arg m "${mode:-}" --argjson mp "$mp" '{path:"/run/secrets",uid:$u,mode:$m,mount_point:$mp}')" + # The identity port's socket dir (ADR-2122 integration, 2026-10-03): devuser can reach it + # through the group, but must be unable to rename it away or plant a replacement beside it. + # That is why it sits in the root-owned secrets mount and not under /run, which devuser owns. + local sd sgid sown probe moved + while IFS=$'\t' read -r sd sgid sown; do + [ -n "$sd" ] || continue + s="$(st "$R$sd")"; read -r uid gid mode _ <<<"$s" + if [ -n "$s" ] && [ "$uid" = "$sown" ] && [ "$gid" = "$sgid" ] && [ "$mode" = 750 ]; then ok=1; else ok=0; fi + row a "$sd" "socket dir 0750, owner $sown, group $sgid" "$([ -n "$s" ] && echo "uid $uid gid $gid mode $mode" || echo absent)" "$ok" \ + "$(jq -nc --arg p "$sd" --arg u "${uid:-}" --arg g "${gid:-}" --arg m "${mode:-}" '{path:$p,uid:$u,gid:$g,mode:$m}')" + probe="$R${sd%/*}/.x1-rehearsal-probe.$$"; moved=no + if [ -e "$R$sd" ] && mv -T -- "$R$sd" "$probe" 2>/dev/null; then + moved=yes; mv -T -- "$probe" "$R$sd" 2>/dev/null || echo "role-isolation-rehearsal: WARNING: could not restore $sd from $probe" >&2 + fi + if mkdir -- "$probe" 2>/dev/null; then rmdir -- "$probe" 2>/dev/null; r=planted; else r=refused; fi + if [ "$moved" = no ] && [ "$r" = refused ]; then ok=1; else ok=0; fi + row a "$sd (rename/replace)" "devuser can neither rename it nor create beside it" "rename $([ "$moved" = yes ] && echo succeeded || echo refused); create beside $r" "$ok" \ + "$(jq -nc --arg p "$sd" --arg mv "$moved" --arg cr "$r" '{path:$p,devuser_rename:$mv,devuser_create_beside:$cr}')" + done < <(jq -r '(.socket_dirs // [])[] | [.path, .gid, .owner_uid] | @tsv' <<<"$REGISTRY") + while IFS=$'\t' read -r role ruid; do + d="/run/secrets/$role"; s="$(st "$R$d")"; read -r uid _ mode dev <<<"$s" + if [ -n "$s" ] && [ "$uid" = "$ruid" ] && [ "$mode" = 500 ]; then + row a "$d" "dir 0500 owned by $role ($ruid)" "uid $uid mode $mode" 1 "$(jq -nc --arg p "$d" --argjson u "$uid" --arg m "$mode" '{path:$p,uid:$u,mode:$m}')" + else + if [ -n "$s" ]; then obs="uid $uid mode $mode"; else obs=absent; fi + row a "$d" "dir 0500 owned by $role ($ruid)" "$obs" 0 "$(jq -nc --arg p "$d" --arg u "${uid:-}" --arg m "${mode:-}" '{path:$p,uid:$u,mode:$m}')" + fi + while IFS= read -r f; do + [ -n "$f" ] || continue + p="$d/$f"; r="$(try_open "$R$p")" + row a "$p" "devuser open() -> EACCES" "$r" "$([ "$r" = EACCES ] && echo 1 || echo 0)" "$(jq -nc --arg p "$p" --arg r "$r" '{path:$p,devuser_open:$r}')" + done < <(jq -r --arg r "$role" '.roles[] | select(.name == $r) | .files[]' <<<"$REGISTRY") + done < <(jq -r '.roles[] | select((.files | length) > 0 or (.programs | length) > 0) | [.name, .uid] | @tsv' <<<"$REGISTRY") + # At-rest copies: refused or absent (absence of a source is caught by the role's own copy). + while IFS= read -r p; do + [ -n "$p" ] || continue + r="$(try_open "$R$p")" + row a "$p" "devuser open() -> EACCES or ENOENT (at rest)" "$r" "$(case "$r" in EACCES|ENOENT) echo 1 ;; *) echo 0 ;; esac)" "$(jq -nc --arg p "$p" --arg r "$r" '{path:$p,devuser_open:$r}')" + done < <(jq -r '[.roles[].at_rest[]] | unique[]' <<<"$REGISTRY") + while IFS= read -r p; do + [ -n "$p" ] || continue + r="$(try_list "$R$p")" + row a "$p/" "devuser opendir() -> EACCES (at rest)" "$r" "$([ "$r" = EACCES ] && echo 1 || echo 0)" "$(jq -nc --arg p "$p" --arg r "$r" '{path:$p,devuser_opendir:$r}')" + done < <(jq -r '[.roles[].at_rest_dirs[]] | unique[]' <<<"$REGISTRY") +} + +# ── (b) programs run as their roles ─────────────────────────────────────────────────────── +check_b() { + local role ruid prog state pid uids r ru eu + if [ "$FLAG" = true ] && [ ! -r "$ROLES_CONF" ]; then + row b /etc/supervisord.roles.conf "present (the flag selects it)" absent 0 '{"path":"/etc/supervisord.roles.conf"}' + fi + if [ "$SUP_RC" = 99 ]; then + row b supervisorctl "status answers" "no output" 0 '{}' + return + fi + while IFS=$'\t' read -r role ruid prog; do + if ! grep -q "^\[program:$prog\]" "$ACTIVE_CONF" 2>/dev/null; then + row b "$prog" "baked in ${ACTIVE_CONF#"$R"}" "no [program:$prog] block" 0 "$(jq -nc --arg p "$prog" --arg c "${ACTIVE_CONF#"$R"}" '{program:$p,config:$c}')" + continue + fi + state="$(sup_state "$prog")"; pid="$(sup_pid "$prog")" + if [ "$state" != RUNNING ] || [ -z "$pid" ]; then + row b "$prog" "RUNNING as $role ($ruid)" "${state:-not in supervisorctl status}" 0 "$(jq -nc --arg p "$prog" --arg s "${state:-missing}" '{program:$p,state:$s}')" + continue + fi + uids="$(proc_uids "$pid")"; read -r ru eu _ _ <<<"$uids" + r="$(try_open "$R/proc/$pid/environ")" + if [ "$ru" = "$ruid" ] && [ "$eu" = "$ruid" ] && [ "$r" = EACCES ]; then + row b "$prog" "RUNNING as $role ($ruid), environ unreadable" "pid $pid uid $ru/$eu, environ $r" 1 "$(jq -nc --arg p "$prog" --argjson pid "$pid" --argjson u "$ru" --arg r "$r" '{program:$p,pid:$pid,uid:$u,environ_devuser_open:$r}')" + else + row b "$prog" "RUNNING as $role ($ruid), environ unreadable" "pid $pid uid ${ru:-?}/${eu:-?}, environ $r" 0 "$(jq -nc --arg p "$prog" --argjson pid "$pid" --arg u "${ru:-}" --arg r "$r" '{program:$p,pid:$pid,uid:$u,environ_devuser_open:$r}')" + fi + done < <(jq -r '.roles[] | .name as $n | .uid as $u | .programs[] | [$n, $u, .] | @tsv' <<<"$REGISTRY") +} + +# ── (c) signing works through the identity port ─────────────────────────────────────────── +VERIFY_JS=' +const path = require("path"); +const [mode, expected, method, url, wantKind] = process.argv.slice(1); +const verifierPath = process.env.RH_VERIFIER; +const out = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(o.ok ? 0 : 1); }; +let resp; try { resp = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch { out({ ok: false, error: "client output is not JSON" }); } +let bridge, tools; +try { + bridge = require(verifierPath); + tools = require(require.resolve("nostr-tools", { paths: [path.dirname(verifierPath)] })); +} catch (e) { out({ ok: false, error: "verifier unavailable: " + e.code }); } +let want = expected || ""; +if (want.startsWith("npub1")) { try { want = tools.nip19.decode(want).data; } catch { out({ ok: false, error: "expected npub undecodable" }); } } +if (!/^[0-9a-f]{64}$/.test(want)) out({ ok: false, error: "no expected public key to compare against" }); +if (mode === "nip98") { + const r = bridge.NostrBridge.verifyNip98(String(resp.header || ""), method, url); + let id = null; try { id = JSON.parse(Buffer.from(String(resp.header).slice(6), "base64").toString()).id; } catch {} + out({ ok: !!r.valid && r.pubkey === want, valid: !!r.valid, pubkey: r.pubkey, pubkey_match: r.pubkey === want, error: r.error || null, event_id: id }); +} +const ev = resp.event || {}; +let valid = false; try { valid = tools.verifyEvent(ev); } catch {} +const kindOk = !wantKind || ev.kind === Number(wantKind); +out({ ok: valid && ev.pubkey === want && kindOk, valid, pubkey: ev.pubkey || null, pubkey_match: ev.pubkey === want, kind: ev.kind ?? null, event_id: ev.id || null }); +' + +# Default relay probe: a real NIP-42 exchange with the loopback relay; the challenge comes from +# the relay and the AUTH event from the port. +RELAY_JS=' +const { execFileSync } = require("child_process"); +const relay = process.argv[1]; +const client = (process.env.RH_SIGN_CLIENT || "").split(/\s+/).filter(Boolean); +const done = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(o.accepted ? 0 : 1); }; +if (typeof WebSocket !== "function") done({ accepted: false, reason: "no WebSocket in this node" }); +const ws = new WebSocket(relay); let sent = null; +setTimeout(() => done({ accepted: false, reason: sent ? "no OK for the AUTH event" : "relay issued no AUTH challenge" }), 8000); +ws.onerror = () => done({ accepted: false, reason: "relay unreachable" }); +ws.onopen = () => setTimeout(() => { if (!sent) ws.send(JSON.stringify(["REQ", "x1-rehearsal", { limit: 0 }])); }, 1500); +ws.onmessage = (m) => { + let msg; try { msg = JSON.parse(String(m.data)); } catch { return; } + if (msg[0] === "AUTH" && !sent) { + let resp; + try { resp = JSON.parse(execFileSync(client[0], [...client.slice(1), "nip42_auth"], { input: JSON.stringify({ key: "core", relay, challenge: msg[1] }) }).toString()); } + catch { done({ accepted: false, reason: "port refused nip42_auth" }); } + if (!resp || !resp.event) done({ accepted: false, reason: "port returned no event" }); + sent = resp.event; ws.send(JSON.stringify(["AUTH", sent])); + } else if (msg[0] === "OK" && sent && msg[1] === sent.id) { + done({ accepted: msg[2] === true, event_id: sent.id, reason: msg[2] ? null : String(msg[3] || "rejected") }); + } +}; +' + +check_c() { + local sock="${RH_IDENTITY_SOCK:-${AGENTBOX_IDENTITY_SOCK:-/run/secrets/ab-identity-port/identity.sock}}" verifier client=() r out ok pod_base url before after calls=0 rdir + local ops=(pods-nip98 forum-event relay-nip42 refuse-dm_unwrap refuse-foreign-url refuse-generic-sign) + verifier="${RH_NIP98_VERIFIER:-}" + if [ -z "$verifier" ]; then + for v in /opt/agentbox/management-api/lib/nostr-bridge.js "$REPO/mcp/servers/nostr-bridge.js"; do [ -r "$v" ] && { verifier="$v"; break; }; done + fi + if [ ! -S "$R$sock" ]; then + row c "$sock" "identity port socket present" absent 0 "$(jq -nc --arg p "$sock" '{path:$p}')" + for op in pubkey "${ops[@]}" receipts; do row c "$op" "made through the port" "not attempted: port socket absent" 0 '{}'; done + return + fi + row c "$sock" "identity port socket present" present 1 "$(jq -nc --arg p "$sock" '{path:$p}')" + if [ -z "$verifier" ] || [ ! -r "$verifier" ]; then + for op in "${ops[@]}"; do row c "$op" "verified by NostrBridge.verifyNip98 / nostr-tools" "not attempted: verifier not found" 0 '{}'; done + return + fi + read -ra client <<<"$SIGN_CLIENT" + rdir="$R/var/lib/agentbox/events/sign" + count_receipts() { cat "$rdir"/*.jsonl 2>/dev/null | wc -l; } + before="$(count_receipts)" + # Each call runs in a command substitution, so the caller counts it (calls=...) itself. + call() { "${client[@]}" "$1" <<<"$2" 2>/dev/null; } + + calls=$((calls + 1)); out="$(call pubkey '{"key":"core"}')"; r="$(jq -r '.pubkey // empty' <<<"$out" 2>/dev/null)" + ok=0; [ -n "$r" ] && [ "$r" = "$EXPECT_CORE" ] && ok=1 + row c pubkey "port's core pubkey equals the identity's public key" "${r:-no pubkey}${EXPECT_CORE:+ vs expected}$([ -z "$EXPECT_CORE" ] && echo ', no expected key (AGENTBOX_X_ONLY_PUBKEY_HEX unset)')" "$ok" "$(jq -nc --arg p "$r" '{pubkey:$p}')" + + pod_base="$(toml_val integrations.solid_pod_rs base_url)"; url="${pod_base%/}/.agentbox-rehearsal/x1-probe" + if [ -z "$pod_base" ]; then + row c pods-nip98 "NIP-98 for the pod verifies under the core key" "no [integrations.solid_pod_rs].base_url to sign for" 0 '{}' + else + calls=$((calls + 1)); out="$(call nip98 "$(jq -nc --arg u "$url" '{key:"core",method:"PUT",url:$u,payload_sha256:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}')")" + r="$(RH_VERIFIER="$verifier" node -e "$VERIFY_JS" nip98 "$EXPECT_CORE" PUT "$url" <<<"$out")"; ok=$? + [ -n "$r" ] || r='{"ok":false,"error":"verifier produced no output"}' + row c pods-nip98 "NIP-98 for the pod verifies under the core key" "$(jq -r '"valid=\(.valid) pubkey_match=\(.pubkey_match)" + (if .error then " (\(.error))" else "" end)' <<<"$r")" "$([ $ok = 0 ] && echo 1 || echo 0)" \ + "$(jq -c --arg u "$url" '{url:$u, pubkey, event_id, verifier:"NostrBridge.verifyNip98"}' <<<"$r")" + fi + + calls=$((calls + 1)); out="$(call forum_event '{"key":"junkiejarvis","kind":1,"content":"x1 role-isolation rehearsal probe (dry run, not published)","dry_run":true}')" + r="$(RH_VERIFIER="$verifier" node -e "$VERIFY_JS" event "$EXPECT_JJ" "" "" 1 <<<"$out")"; ok=$? + [ -n "$r" ] || r='{"ok":false,"error":"verifier produced no output"}' + row c forum-event "dry-run forum event verifies under JunkieJarvis's key" "$(jq -r '"valid=\(.valid) pubkey_match=\(.pubkey_match)" + (if .error then " (\(.error))" else "" end)' <<<"$r")" "$([ $ok = 0 ] && echo 1 || echo 0)" \ + "$(jq -c '{pubkey, event_id, kind, verifier:"nostr-tools verifyEvent"}' <<<"$r")" + + if [ -n "${RH_RELAY_AUTH:-}" ]; then r="$(RH_SIGN_CLIENT="$SIGN_CLIENT" "$RH_RELAY_AUTH" "$RELAY_URL")"; ok=$? + else r="$(RH_SIGN_CLIENT="$SIGN_CLIENT" node -e "$RELAY_JS" "$RELAY_URL")"; ok=$?; fi + calls=$((calls + 1)) + row c relay-nip42 "NIP-42 AUTH from the port is accepted by $RELAY_URL" "$(jq -r 'if .accepted then "accepted" else "refused: \(.reason)" end' <<<"$r" 2>/dev/null || echo 'no answer')" "$([ $ok = 0 ] && echo 1 || echo 0)" \ + "$(jq -c --arg relay "$RELAY_URL" '{relay:$relay, event_id:(.event_id // null)}' <<<"$r" 2>/dev/null || echo '{}')" + + refuse() { # + local o rc + calls=$((calls + 1)); o="$(call "$2" "$3")"; rc=$? + if [ $rc != 0 ]; then row c "$1" "refused" "refused: $(jq -r '.refused.reason? // .refused // "no reason"' <<<"$o" 2>/dev/null)" 1 "$(jq -nc --arg op "$2" --argjson rc "$rc" '{op:$op,exit:$rc}')" + else row c "$1" "refused" "ADMITTED" 0 "$(jq -nc --arg op "$2" '{op:$op,exit:0}')"; fi + } + refuse refuse-dm_unwrap dm_unwrap '{"envelope":{}}' + refuse refuse-foreign-url nip98 '{"key":"core","method":"GET","url":"https://not-allowlisted.invalid/x"}' + refuse refuse-generic-sign sign '{"event":{"kind":1,"content":"x","tags":[]}}' + + after="$(count_receipts)" + ok=0; [ $((after - before)) -ge "$calls" ] && ok=1 + row c receipts "one sign receipt line per call (>= $calls)" "$((after - before)) new lines" "$ok" "$(jq -nc --arg d "/var/lib/agentbox/events/sign" --argjson n $((after - before)) --argjson c "$calls" '{dir:$d,new_lines:$n,calls:$c}')" +} + +# ── (d) the producer still makes a block ────────────────────────────────────────────────── +check_d() { + local role ruid chain port iv prog on baked tip h t age pid ru eu n=0 deadline h2 obs + while IFS=$'\t' read -r role ruid chain port iv prog on baked; do + n=$((n + 1)) + # The manifest and the image's plan must agree before a tip means anything: the live + # /etc/agentbox.toml is a bind of the repo file, the plan was resolved at build time. + if [ "$on" != "$baked" ]; then + if [ "$on" = true ]; then obs="the manifest enables sidestr:$chain but the image's plan delivers $role nothing; rebuild before rehearsing" + else obs="the image bakes $role but the manifest disables sidestr:$chain; rebuild before rehearsing"; fi + row d "sidestr:$chain" "manifest and image agree on sidestr:$chain" "$obs" 0 \ + "$(jq -nc --arg c "$chain" --argjson on "$on" --argjson b "$baked" '{chain:$c,manifest_enabled:$on,baked:$b}')" + continue + fi + pid="$(sup_pid "$prog")"; read -r ru eu _ _ <<<"$(proc_uids "${pid:-0}")" + if [ -n "$pid" ] && [ "$ru" = "$ruid" ] && [ "$eu" = "$ruid" ]; then + row d "$prog" "producer runs as $role ($ruid)" "pid $pid uid $ru" 1 "$(jq -nc --arg p "$prog" --argjson pid "$pid" --argjson u "$ru" '{program:$p,pid:$pid,uid:$u}')" + else + if [ -n "$pid" ]; then obs="pid $pid uid ${ru:-?}"; else obs="not RUNNING under supervisor"; fi + row d "$prog" "producer runs as $role ($ruid)" "$obs" 0 "$(jq -nc --arg p "$prog" --arg pid "${pid:-}" --arg u "${ru:-}" '{program:$p,pid:$pid,uid:$u}')" + fi + tip="$("$CURL" -fsS --max-time 5 "http://127.0.0.1:$port/tip" 2>/dev/null)" + h="$(jq -r '.height // empty' <<<"$tip" 2>/dev/null)"; t="$(jq -r '.time // empty' <<<"$tip" 2>/dev/null)" + if [ -z "$h" ] || [ -z "$t" ]; then + row d "sidestr:$chain tip" "a block younger than $((2 * iv))s on :$port" "tip unreachable" 0 "$(jq -nc --argjson port "$port" '{port:$port}')" + continue + fi + age=$((NOW - t)) + if [ "$age" -gt $((2 * iv)) ] && [ "$WAIT" = 1 ]; then + deadline=$((NOW + 2 * iv)) + while [ "$(date -u +%s)" -lt "$deadline" ]; do + sleep 30 + h2="$(jq -r '.height // empty' <<<"$("$CURL" -fsS --max-time 5 "http://127.0.0.1:$port/tip" 2>/dev/null)" 2>/dev/null)" + if [ -n "$h2" ] && [ "$h2" -gt "$h" ]; then age=0; h="$h2"; break; fi + done + fi + row d "sidestr:$chain tip" "a block younger than $((2 * iv))s on :$port" "height $h, newest block ${age}s old" "$([ "$age" -le $((2 * iv)) ] && echo 1 || echo 0)" \ + "$(jq -nc --argjson port "$port" --argjson h "$h" --argjson a "$age" --argjson iv "$iv" '{port:$port,height:$h,block_age_s:$a,interval_s:$iv}')" + done < <(jq -r '.roles[] | select(.chain and (.name | startswith("ab-sidestr-")) and (.enabled or .baked)) + | [.name, .uid, .chain, .port, .interval, (.programs[0] // ""), .enabled, .baked] | @tsv' <<<"$REGISTRY") + # (d) is about each enabled chain; with none enabled in the manifest and none baked there is + # nothing to produce, which is a pass, not a missing probe. + [ "$n" -gt 0 ] || row d producers "every chain the manifest enables produces" "no chain enabled in the manifest and none baked" 1 \ + "$(jq -nc --argjson c "$(jq -c '[.roles[] | select(.chain) | .chain]' <<<"$REGISTRY")" '{chains:$c,enabled:[]}')" +} + +# ── (e) no secret is ambient (names, never values) ──────────────────────────────────────── +# hits_evidence