From a10f336fca4c2ad2bdf0b7222545218298757569 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 08:55:53 +0000 Subject: [PATCH 01/58] docs(security): prompt egress register (N-7), rendered from egress-policy.json Catalogue only; no product behaviour changes. config/egress-policy.json gains a `.register` key: 31 routes by which a prompt, code, a secret-adjacent string or a key leaves the box, each with what leaves, destination class, gate, accepted-egress record and notes, every claim cited path:line at 0919dc39a. The ADR-2026 `paths` contract is untouched (egress-boundary: 25 passed). scripts/ci/render-egress-register.js renders the table into docs/SECURITY-profiles.md between markers; --check (wired into invariants.yml beside check-adr-index) fails on drift, an unmarked gap or a citation outside its file. Gaps are marked, not filled: 5 routes have no gate, 18 have no accepted-egress record. Primary-model egress is recorded as accepted per Q14; provider credentials are listed as devuser-class per Q8. Linked from docs/LAN-door-threat-model.md (G-10) and ADR-2090 (dated cross-reference, Decision unchanged). Co-Authored-By: jjohare --- .github/workflows/invariants.yml | 10 + config/egress-policy.json | 494 +++++++++++++++++- docs/LAN-door-threat-model.md | 12 + docs/SECURITY-profiles.md | 50 ++ .../ADR-2090-skill-routing-prompt-egress.md | 12 + scripts/ci/render-egress-register.js | 173 ++++++ 6 files changed, 750 insertions(+), 1 deletion(-) create mode 100755 scripts/ci/render-egress-register.js diff --git a/.github/workflows/invariants.yml b/.github/workflows/invariants.yml index 762be31e0..813ec5efc 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/**' @@ -136,6 +139,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/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/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/SECURITY-profiles.md b/docs/SECURITY-profiles.md index 9e657b6e6..9284c741b 100644 --- a/docs/SECURITY-profiles.md +++ b/docs/SECURITY-profiles.md @@ -76,3 +76,53 @@ 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`. + + 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/scripts/ci/render-egress-register.js b/scripts/ci/render-egress-register.js new file mode 100755 index 000000000..3ece7abad --- /dev/null +++ b/scripts/ci/render-egress-register.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * render-egress-register.js — the N-7 prompt egress register, rendered from its one source. + * + * `config/egress-policy.json` `.register` is the machine record: one row per route by which a + * prompt, code, a secret-adjacent string or a key can leave the container. This script renders + * that record into `docs/SECURITY-profiles.md` between the two markers below, so the table a + * reviewer reads and the record a tool reads cannot drift apart. + * + * node scripts/ci/render-egress-register.js rewrite the table in place + * node scripts/ci/render-egress-register.js --check exit 1 if the file differs from a fresh + * render, or the record is malformed + * + * --check also validates the record itself: + * - every row carries the required fields, a known `leaves` class and a known destination class; + * - a row whose gate begins "none" carries the `no-gate` marker, and a row whose accepted-egress + * record begins "none" carries `no-accepted-record` (a gap is marked, never implied); + * - every backticked `path:line` or `path:line-line` citation names a file that exists in the + * checkout and a line range inside it. A citation whose file shrank below the cited line is + * stale and must be re-verified against `register.verified_commit`. + * + * Exit codes: 0 in sync and valid; 1 drift or validation errors; 2 usage or I/O error. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const POLICY = path.join(ROOT, 'config', 'egress-policy.json'); +const BEGIN = ''; +const END = ''; +const REQUIRED = ['id', 'route', 'leaves', 'destination_class', 'destination', 'gate', 'accepted_egress', 'markers', 'notes']; +const CITE = /`([A-Za-z0-9_.\/-]+\.[A-Za-z][A-Za-z0-9]*):(\d+)(?:-(\d+))?`/g; + +/** Escape a table cell: pipes split columns and newlines end rows. */ +function cell(text) { + return String(text).replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +/** Render the generated block (markers included) from the register object. */ +function render(reg) { + const markerLabel = { 'no-gate': 'no gate', 'no-accepted-record': 'no accepted record' }; + const lines = [ + BEGIN, + '', + `Source: [\`config/egress-policy.json\`](../config/egress-policy.json) \`.register\`. ` + + `Citations verified at \`${reg.verified_commit.slice(0, 9)}\` on ${reg.verified_on}. ` + + `${reg.routes.length} routes.`, + '', + reg.scope, + '', + reg.credential_class, + '', + '| id | Route | What leaves | Destination class | Gate | Accepted-egress record | Notes |', + '|---|---|---|---|---|---|---|', + ]; + for (const r of reg.routes) { + const flags = r.markers.map((m) => `**[marker: ${markerLabel[m] || m}]**`).join(' '); + lines.push('| ' + [ + `\`${r.id}\``, + r.route, + r.leaves.join(', '), + `${r.destination_class}: ${r.destination}`, + r.gate, + r.accepted_egress, + flags ? `${flags} ${r.notes}` : r.notes, + ].map(cell).join(' | ') + ' |'); + } + const gateless = reg.routes.filter((r) => r.markers.includes('no-gate')).map((r) => `\`${r.id}\``); + const unrecorded = reg.routes.filter((r) => r.markers.includes('no-accepted-record')).map((r) => `\`${r.id}\``); + lines.push( + '', + `Marked with no gate (${gateless.length}): ${gateless.join(', ') || 'none'}.`, + '', + `Marked with no accepted-egress record (${unrecorded.length}): ${unrecorded.join(', ') || 'none'}.`, + '', + END, + ); + return lines.join('\n'); +} + +/** Structural and citation checks; returns a list of error strings. */ +function validate(reg) { + const errors = []; + for (const k of ['verified_commit', 'verified_on', 'rendered_to', 'scope', 'credential_class', 'routes']) { + if (reg[k] === undefined) errors.push(`register.${k} missing`); + } + if (!Array.isArray(reg.routes) || reg.routes.length === 0) return errors.concat('register.routes empty'); + const ids = new Set(); + for (const r of reg.routes) { + const where = `route ${r.id || '(no id)'}`; + for (const f of REQUIRED) if (r[f] === undefined || r[f] === '') errors.push(`${where}: ${f} missing`); + if (ids.has(r.id)) errors.push(`${where}: duplicate id`); + ids.add(r.id); + for (const l of r.leaves || []) { + if (!reg.leaves_vocabulary.includes(l)) errors.push(`${where}: unknown leaves class "${l}"`); + } + if (!reg.destination_classes.includes(r.destination_class)) { + errors.push(`${where}: unknown destination class "${r.destination_class}"`); + } + for (const m of r.markers || []) { + if (!Object.prototype.hasOwnProperty.call(reg.marker_vocabulary, m)) errors.push(`${where}: unknown marker "${m}"`); + } + if (/^none\b/i.test(r.gate) && !r.markers.includes('no-gate')) errors.push(`${where}: gate is none but no-gate marker absent`); + if (/^none\b/i.test(r.accepted_egress) && !r.markers.includes('no-accepted-record')) { + errors.push(`${where}: accepted-egress is none but no-accepted-record marker absent`); + } + for (const field of ['destination', 'gate', 'accepted_egress', 'notes']) { + for (const m of String(r[field] || '').matchAll(CITE)) { + const [, rel, a, b] = m; + const abs = path.join(ROOT, rel); + if (!fs.existsSync(abs)) { errors.push(`${where}: cites missing file ${rel}`); continue; } + const count = fs.readFileSync(abs, 'utf8').split('\n').length; + const hi = Number(b || a); + if (Number(a) < 1 || hi < Number(a) || hi > count) { + errors.push(`${where}: ${rel}:${a}${b ? '-' + b : ''} outside the file (${count} lines); re-verify`); + } + } + } + } + return errors; +} + +/** Replace the marked block in `text`, or append it under a register heading. */ +function splice(text, block) { + const i = text.indexOf(BEGIN); + const j = text.indexOf(END); + if (i === -1 && j === -1) return null; + if (i === -1 || j === -1 || j < i) throw new Error('register markers are unbalanced'); + return text.slice(0, i) + block + text.slice(j + END.length); +} + +function main(argv) { + const check = argv.includes('--check'); + let doc; + try { + doc = JSON.parse(fs.readFileSync(POLICY, 'utf8')); + } catch (e) { + process.stderr.write(`render-egress-register: cannot read ${POLICY}: ${e.message}\n`); + return 2; + } + const reg = doc.register; + if (!reg) { process.stderr.write('render-egress-register: config/egress-policy.json has no .register\n'); return 2; } + const target = path.join(ROOT, reg.rendered_to || 'docs/SECURITY-profiles.md'); + const errors = validate(reg); + const block = render(reg); + const current = fs.readFileSync(target, 'utf8'); + const next = splice(current, block); + if (check) { + if (next === null) errors.push(`${path.relative(ROOT, target)} has no egress-register markers`); + else if (next !== current) { + errors.push(`${path.relative(ROOT, target)} is out of date: run node scripts/ci/render-egress-register.js`); + } + for (const e of errors) process.stderr.write(`render-egress-register: ${e}\n`); + if (errors.length) return 1; + process.stdout.write(`render-egress-register: ${reg.routes.length} routes in sync and valid\n`); + return 0; + } + if (errors.length) { + for (const e of errors) process.stderr.write(`render-egress-register: ${e}\n`); + return 1; + } + const out = next === null + ? current.replace(/\s*$/, '\n') + `\n## Prompt egress register — ${reg.verified_on}\n\n${block}\n` + : next; + fs.writeFileSync(target, out); + process.stdout.write(`render-egress-register: wrote ${reg.routes.length} routes to ${path.relative(ROOT, target)}\n`); + return 0; +} + +if (require.main === module) process.exit(main(process.argv.slice(2))); +module.exports = { render, validate, splice, BEGIN, END }; From 2f06596b0f204d4fb2c6ee0640ef2148c1c0ceba Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 08:57:09 +0000 Subject: [PATCH 02/58] docs(adr): re-verify ADR-2013 and ADR-2026 after the egress register a10f336fc touched governed paths of both records (invariants.yml; the egress-policy.json `register` key) without touching either decision. Dated notes record what changed and what was checked; verified_commit bumped. Status axes unchanged. Co-Authored-By: jjohare --- docs/adr/ADR-2013-loopback-publish-except-9096.md | 6 +++++- docs/adr/ADR-2026-session-mirror-egress-boundary.md | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-2013-loopback-publish-except-9096.md b/docs/adr/ADR-2013-loopback-publish-except-9096.md index c993501ca..fa8c1fe2f 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: a10f336fca4c2ad2bdf0b7222545218298757569 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. diff --git a/docs/adr/ADR-2026-session-mirror-egress-boundary.md b/docs/adr/ADR-2026-session-mirror-egress-boundary.md index 5c3c8d8ed..01c5e3f44 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: a10f336fca4c2ad2bdf0b7222545218298757569 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. From 7d5fd4783367fc647d830988d3a0cc0e8d3e2ffc Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 08:57:51 +0000 Subject: [PATCH 03/58] docs(adr): ADR-2103 re-verified after the chain-event announce change b6c44c9a5 changed run-producer.sh (a governed path) without re-verifying the record; the invariants job has failed at the ADR index step since. The decision is unaffected. Co-Authored-By: jjohare --- ...er-profile-are-configuration-behind-the-p21-gate.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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..76b9b0750 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,7 +7,7 @@ implementation_status: partial activation_status: inactive supersedes: [] superseded_by: [] -verified_commit: 05d886bba7cc7c73c4de8ae47680785c4f81c079 +verified_commit: 0919dc39afb7d4ab22bfc14a431f0d55ef1b81b3 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] 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) @@ -568,3 +568,11 @@ 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`. From 495f29c2d0f5ce3ef154efc4d2f989781a05e952 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 08:58:23 +0000 Subject: [PATCH 04/58] test(runtime-contract): RC-X1-01..05, red: the three custody bypasses (W0) Tests first for custody X-1 step 1, workstream W0. All five fail against origin/main: - RC-X1-01: a probe planted in ~/workspace/.cargo/bin runs on the pre-W0 root PATH (control) and must not after _ab_root_path_sanitise; the sanitiser runs before the stage dispatch; the profile snippet appends the cargo bin for devuser shells only. - RC-X1-02: Stage B is one-shot per container start, guarded by a root 0700 state dir in sticky /tmp (not /run: that tmpfs is owned by uid 1000, so devuser can rename anything directly under it). - RC-X1-03: the baked /etc/group gives root no members (live check gated on a rebuilt image). - RC-X1-04: the docker socket chmod o+rw runs only with role_isolation off; on, the host inode is left alone and a widened socket is reported degraded. - RC-X1-05: allowlist fixtures, a fake daemon and the real docker CLI for the GET-only read proxy (ps/inspect/logs pass, exec/run/archive/export refused). Co-Authored-By: jjohare --- tests/runtime-contract/RC-X1-01.sh | 137 +++++++++++++ tests/runtime-contract/RC-X1-02.sh | 124 ++++++++++++ tests/runtime-contract/RC-X1-03.sh | 42 ++++ tests/runtime-contract/RC-X1-04.sh | 111 +++++++++++ tests/runtime-contract/RC-X1-05.node-test.cjs | 187 ++++++++++++++++++ .../fixtures/docker-read-proxy-allowlist.json | 44 +++++ 6 files changed, 645 insertions(+) create mode 100755 tests/runtime-contract/RC-X1-01.sh create mode 100755 tests/runtime-contract/RC-X1-02.sh create mode 100755 tests/runtime-contract/RC-X1-03.sh create mode 100755 tests/runtime-contract/RC-X1-04.sh create mode 100644 tests/runtime-contract/RC-X1-05.node-test.cjs create mode 100644 tests/runtime-contract/fixtures/docker-read-proxy-allowlist.json diff --git a/tests/runtime-contract/RC-X1-01.sh b/tests/runtime-contract/RC-X1-01.sh new file mode 100755 index 000000000..ed037918c --- /dev/null +++ b/tests/runtime-contract/RC-X1-01.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# RC-X1-01 — root boot never executes a binary from a devuser-writable directory +# (custody X-1 step 1, workstream W0, bypass 2). +# +# Before W0 the entrypoint put /home/devuser/workspace/.cargo/bin at the FRONT of +# root's PATH (Stage A for PID 1 and every supervised child, Stage B again for +# [program:bootstrap]). devuser owns that directory, so a binary planted there +# under the name of any tool the root boot calls ran as root. +# +# Asserts, against the real functions extracted from config/entrypoint-unified.sh: +# 1. the probe planted under the cargo bin dir WOULD run on the old PATH (control) +# 2. after _ab_root_path_sanitise it is never run, by name, for tools Stage B calls +# 3. only /nix/store/* and the fixed image system dirs survive sanitising +# 4. relative, dotted and empty entries are dropped; an empty result falls back +# 5. the sanitiser runs before the stage dispatch, so it covers Stage A and B +# 6. no root-side PATH assignment mentions the workspace any more +# 7. the shell profile snippet appends the cargo bin for devuser only, never root +# +# No container, no Docker, nothing executed as root. Exit 0 = all pass, 1 = any fail. +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRY="${HERE}/../../config/entrypoint-unified.sh" +PASS=0; FAIL=0 +_ok() { PASS=$((PASS + 1)); printf 'ok %d - %s\n' "$((PASS + FAIL))" "$1"; } +_bad() { FAIL=$((FAIL + 1)); printf 'not ok %d - %s\n' "$((PASS + FAIL))" "$1"; [ -n "${2:-}" ] && printf '# %s\n' "$2"; } +_done() { printf '1..%d\n# RC-X1-01: %d passed, %d failed\n' "$((PASS + FAIL))" "$PASS" "$FAIL"; [ "$FAIL" -eq 0 ]; exit $?; } + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/rc-x1-01.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT + +_extract() { awk -v n="$1" '$0 ~ "^"n"\\(\\) \\{" {f=1} f {print} f && /^}$/ {exit}' "$ENTRY"; } +FN="$(_extract _ab_root_path_sanitise)" +if [ -z "$FN" ]; then _bad "_ab_root_path_sanitise() is defined in the entrypoint"; _done; fi +eval "$FN" +_ok "_ab_root_path_sanitise() is defined in the entrypoint" + +# ── 1-2. probe planted under the workspace cargo bin dir ────────────────────── +CARGO_BIN="${TMP}/home/devuser/workspace/.cargo/bin" +mkdir -p "$CARGO_BIN" +HITS="${TMP}/probe.hits" +# Tools the root boot invokes by bare name (Stage A and Stage B both call these). +TOOLS="mkdir stat chown node jq sed awk" +for t in $TOOLS; do + printf '#!/bin/sh\necho "%s" >>"%s"\nexit 0\n' "$t" "$HITS" >"${CARGO_BIN}/${t}" + chmod 0755 "${CARGO_BIN}/${t}" +done +DIRTY="${CARGO_BIN}:/home/devuser/workspace/.cargo/bin:${PATH}" + +( PATH="$DIRTY"; mkdir -p "${TMP}/control" ) 2>/dev/null +if [ -s "$HITS" ]; then _ok "control: on the pre-W0 PATH shape the planted probe runs" +else _bad "control: the probe must fire on the dirty PATH (else this test is vacuous)"; fi +rm -f "$HITS" + +CLEAN="$(_ab_root_path_sanitise "$DIRTY")" +( + PATH="$CLEAN" + mkdir -p "${TMP}/clean" + stat / >/dev/null 2>&1 + chown "$(id -u)" "${TMP}/clean" 2>/dev/null + command -v node >/dev/null 2>&1 && node -e 0 >/dev/null 2>&1 + command -v jq >/dev/null 2>&1 && jq -n 1 >/dev/null 2>&1 + echo x | sed 's/x/y/' >/dev/null 2>&1 + echo x | awk '{print}' >/dev/null 2>&1 +) 2>/dev/null +if [ ! -e "$HITS" ]; then _ok "after sanitising, no planted probe runs for: ${TOOLS}" +else _bad "a planted probe ran as the boot user" "$(tr '\n' ' ' <"$HITS")"; fi + +for t in $TOOLS; do + r="$(PATH="$CLEAN" command -v "$t" 2>/dev/null || true)" + case "$r" in "${TMP}"/*|/home/*) _bad "'$t' resolves into a devuser-writable dir: $r"; continue ;; esac +done +_ok "no tool resolves into the workspace after sanitising" + +# ── 3. only store and fixed system dirs survive ────────────────────────────── +bad_entries="" +IFS=: read -r -a _parts <<<"$CLEAN" +for e in "${_parts[@]}"; do + case "$e" in + /nix/store/?*|/usr/local/bin|/usr/local/sbin|/bin|/sbin|/usr/bin|/usr/sbin) ;; + *) bad_entries="${bad_entries} ${e}" ;; + esac +done +[ -z "$bad_entries" ] && _ok "sanitised PATH holds only /nix/store/* and image system dirs" \ + || _bad "sanitised PATH holds untrusted entries" "$bad_entries" + +# ── 4. hostile shapes ──────────────────────────────────────────────────────── +got="$(_ab_root_path_sanitise ".:bin::/nix/store/../tmp:/nix/store/x/../../home/devuser:/usr/bin:/usr/bin:/home/devuser/.local/bin:/tmp:/run/agentbox:/var/run")" +[ "$got" = "/usr/bin" ] && _ok "relative, dotted, duplicate and devuser-owned entries are dropped" \ + || _bad "hostile entries must be dropped" "got: $got" +got="$(_ab_root_path_sanitise "/nix/store/*:/nix/store/abc-x/bin")" +[ "$got" = "/nix/store/abc-x/bin" ] && _ok "glob characters are neither expanded nor kept" \ + || _bad "glob entry must be dropped, not expanded" "got: $got" +got="$(_ab_root_path_sanitise "/home/devuser/workspace/.cargo/bin")" +[ "$got" = "/usr/local/bin:/bin:/usr/bin" ] && _ok "an all-untrusted PATH falls back to the image system dirs" \ + || _bad "empty result must fall back to /usr/local/bin:/bin:/usr/bin" "got: $got" + +# ── 5. placement: before the stage dispatch ────────────────────────────────── +call_line="$(grep -nE '^(export )?PATH="\$\(_ab_root_path_sanitise "\$PATH"\)"' "$ENTRY" | head -1 | cut -d: -f1)" +dispatch_line="$(grep -n '^if \[ "${AGENTBOX_BOOTSTRAP_STAGE:-A}" = "B" \]; then' "$ENTRY" | head -1 | cut -d: -f1)" +if [ -n "$call_line" ] && [ -n "$dispatch_line" ] && [ "$call_line" -lt "$dispatch_line" ]; then + _ok "PATH is sanitised at line ${call_line}, before the stage dispatch at ${dispatch_line} (covers Stage A and B)" +else + _bad "PATH must be sanitised before the stage dispatch" "call=${call_line:-none} dispatch=${dispatch_line:-none}" +fi + +# ── 6. no root-side workspace PATH assignment ──────────────────────────────── +grep -q '^_ab_cargo_bin_on_path' "$ENTRY" && _bad "_ab_cargo_bin_on_path (root PATH prepend) must be gone" \ + || _ok "_ab_cargo_bin_on_path (root PATH prepend) is gone" +# Any live PATH= assignment naming the workspace must be heredoc text (escaped \$PATH). +live="$(grep -nE 'PATH="[^"]*/home/devuser|PATH="[^"]*\$\{?WORKSPACE' "$ENTRY" | grep -v '\\\$PATH' || true)" +[ -z "$live" ] && _ok "no root-executed PATH assignment names the workspace" \ + || _bad "root-executed PATH assignment names the workspace" "$live" + +# ── 7. the profile snippet: devuser shells only, appended ──────────────────── +SNIP="$(awk '/^# ADR-2029 D4: cargo-installed binaries/{f=1;next} f && /^fi$/{print; exit} f{print}' "$ENTRY" | sed 's/\\\$/$/g')" +if [ -z "$SNIP" ]; then + _bad "runtime-env cargo-bin snippet found" +else + FAKE_BIN="/home/devuser/workspace/.cargo/bin" + out_root="$(bash -c 'id() { echo 0; }; PATH=/usr/bin; '"$SNIP"'; printf %s "$PATH"')" + [ "$out_root" = "/usr/bin" ] && _ok "root shells sourcing runtime-env do not get the cargo bin" \ + || _bad "root shell PATH must be untouched" "got: $out_root" + if [ -d "$FAKE_BIN" ]; then + out_dev="$(bash -c 'id() { echo 1000; }; PATH=/usr/bin; '"$SNIP"'; printf %s "$PATH"')" + [ "$out_dev" = "/usr/bin:${FAKE_BIN}" ] && _ok "devuser shells get the cargo bin APPENDED (image tools win)" \ + || _bad "devuser PATH must end with the cargo bin" "got: $out_dev" + else + echo "ok $((PASS + FAIL + 1)) # SKIP ${FAKE_BIN} absent on this host; devuser append path unprovable here"; PASS=$((PASS + 1)) + fi +fi +FISH="$(awk "/^cat > \"\\\$FISH_CONF_D\/agentbox-runtime.fish\" <<'FISHEOF'/{f=1;next} f && /^FISHEOF\$/{exit} f{print}" "$ENTRY")" +if printf '%s' "$FISH" | grep -q 'id -u' && printf '%s' "$FISH" | grep -q 'fish_add_path .*--append.*\.cargo/bin'; then + _ok "fish conf.d appends the cargo bin for non-root shells only" +else + _bad "fish conf.d must append the cargo bin behind an id -u guard" +fi + +_done diff --git a/tests/runtime-contract/RC-X1-02.sh b/tests/runtime-contract/RC-X1-02.sh new file mode 100755 index 000000000..c837af795 --- /dev/null +++ b/tests/runtime-contract/RC-X1-02.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# RC-X1-02 — Stage B is one-shot per container start (custody X-1 step 1, W0, bypass 2). +# +# [program:bootstrap] runs the entrypoint's Stage B as root, and the supervisor +# socket lets devuser `supervisorctl start bootstrap` (flake.nix:2305-2307, kept +# by Q10). W0 makes a second Stage B run a no-op, guarded by a sentinel that only +# root can create or remove. +# +# Where the sentinel lives matters: /run, /var/run and /var/log are tmpfs mounts +# OWNED BY UID 1000 (flake.nix:3200-3203), so devuser can rename or delete any +# entry directly under them, root-owned or not. /tmp is root:root 1777 (sticky): +# devuser cannot rename or unlink root's entries there, and it is a fresh tmpfs +# per container start. The sentinel directory is /tmp/.agentbox-root (root 0700) +# until W1 gives /run/secrets its own root-owned mount. +# +# Asserts, against the real functions extracted from config/entrypoint-unified.sh: +# 1. Stage A's prepare creates the state dir 0700 +# 2. the first Stage B claim succeeds, every later one is a no-op +# 3. a new container start (prepare again) re-arms exactly one claim +# 4. an untrusted state dir (wrong owner, loose mode, symlink) refuses the claim +# 5. prepare moves a squatted path aside rather than trusting or following it +# 6. the sentinel path is not under a devuser-owned tmpfs; /tmp is root 1777 here +# 7. the Stage B prologue claims before Phase 6 and exits 0 on a replay +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRY="${HERE}/../../config/entrypoint-unified.sh" +PASS=0; FAIL=0 +_ok() { PASS=$((PASS + 1)); printf 'ok %d - %s\n' "$((PASS + FAIL))" "$1"; } +_bad() { FAIL=$((FAIL + 1)); printf 'not ok %d - %s\n' "$((PASS + FAIL))" "$1"; [ -n "${2:-}" ] && printf '# %s\n' "$2"; } +_skip() { PASS=$((PASS + 1)); printf 'ok %d # SKIP %s\n' "$((PASS + FAIL))" "$1"; } +_done() { printf '1..%d\n# RC-X1-02: %d passed, %d failed\n' "$((PASS + FAIL))" "$PASS" "$FAIL"; [ "$FAIL" -eq 0 ]; exit $?; } + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/rc-x1-02.XXXXXX")"; trap 'chmod -R u+rwx "$TMP" 2>/dev/null; rm -rf "$TMP"' EXIT +_extract() { awk -v n="$1" '$0 ~ "^"n"\\(\\) \\{" {f=1} f {print} f && /^}$/ {exit}' "$ENTRY"; } +for fn in _ab_root_state_dir_ok _ab_root_state_dir_prepare _ab_stage_b_claim; do + body="$(_extract "$fn")" + if [ -z "$body" ]; then _bad "${fn}() is defined in the entrypoint"; _done; fi + eval "$body" +done +_ok "state-dir and claim functions are defined in the entrypoint" + +ME="$(id -u)" # stands in for uid 0: the functions take the expected owner uid +D="${TMP}/agentbox-root" +_claim() { local rc=0; _ab_stage_b_claim "$1" "${2:-$ME}" || rc=$?; echo "$rc"; } + +# ── 1. prepare ─────────────────────────────────────────────────────────────── +_ab_root_state_dir_prepare "$D" "$ME" >/dev/null 2>&1 +[ -d "$D" ] && [ "$(stat -c '%u %a' "$D")" = "${ME} 700" ] && _ok "prepare creates the state dir owned by the boot uid, mode 0700" \ + || _bad "prepare must create the state dir 0700" "$(stat -c '%u %a' "$D" 2>&1)" + +# ── 2. one-shot ────────────────────────────────────────────────────────────── +[ "$(_claim "$D")" = 0 ] && _ok "first Stage B claim succeeds" || _bad "first claim must succeed" +r2="$(_claim "$D")"; r3="$(_claim "$D")" +[ "$r2" = 1 ] && [ "$r3" = 1 ] && _ok "second and third claims are no-ops (rc 1)" || _bad "replays must be no-ops" "rc2=$r2 rc3=$r3" + +# ── 3. re-arm on a new container start ─────────────────────────────────────── +_ab_root_state_dir_prepare "$D" "$ME" >/dev/null 2>&1 +r1="$(_claim "$D")"; r2="$(_claim "$D")" +[ "$r1" = 0 ] && [ "$r2" = 1 ] && _ok "a new Stage A re-arms exactly one claim" || _bad "Stage A must re-arm once" "rc1=$r1 rc2=$r2" + +# ── 4. untrusted state dir refuses ─────────────────────────────────────────── +if [ "$ME" != 0 ]; then + [ "$(_claim "$D" 0)" = 2 ] && _ok "a state dir not owned by root refuses the claim (rc 2)" \ + || _bad "wrong-owner state dir must refuse" +else + _skip "running as root: cannot fake a non-root owner" +fi +L="${TMP}/loose"; mkdir -p "$L"; chmod 0777 "$L" +[ "$(_claim "$L")" = 2 ] && _ok "a group/other-writable state dir refuses the claim" || _bad "loose-mode state dir must refuse" +T="${TMP}/target"; mkdir -p "$T"; chmod 0700 "$T"; ln -s "$T" "${TMP}/link" +[ "$(_claim "${TMP}/link")" = 2 ] && _ok "a symlinked state dir refuses the claim" || _bad "symlinked state dir must refuse" +[ "$(_claim "${TMP}/absent")" = 2 ] && _ok "a missing state dir refuses the claim (fail closed)" || _bad "missing state dir must refuse" + +# ── 5. squatters are moved aside, never followed ───────────────────────────── +S="${TMP}/squat"; mkdir -p "$S"; chmod 0777 "$S"; : >"${S}/planted" +_ab_root_state_dir_prepare "$S" "$ME" >/dev/null 2>&1 +if [ "$(stat -c '%a' "$S" 2>/dev/null)" = 700 ] && [ ! -e "${S}/planted" ] && ls -d "${S}".squatted.* >/dev/null 2>&1; then + _ok "a loose pre-existing dir is moved aside and recreated 0700" +else + _bad "squatted dir must be moved aside and recreated" +fi +SL="${TMP}/squatlink"; ln -s "$T" "$SL"; : >"${T}/keep" +_ab_root_state_dir_prepare "$SL" "$ME" >/dev/null 2>&1 +if [ -d "$SL" ] && [ ! -L "$SL" ] && [ -e "${T}/keep" ] && [ "$(stat -c '%a' "$T")" = 700 ]; then + _ok "a symlink at the state path is moved aside; its target is untouched" +else + _bad "symlink squatter must be replaced without touching its target" +fi + +# ── 6. location ────────────────────────────────────────────────────────────── +SDIR="$(grep -E '^AB_ROOT_STATE_DIR=' "$ENTRY" | head -1 | cut -d= -f2- | tr -d '"')" +case "$SDIR" in + /tmp/?*) _ok "sentinel dir is ${SDIR} (sticky /tmp, not a devuser-owned tmpfs)" ;; + "") _bad "AB_ROOT_STATE_DIR constant is defined" ;; + *) _bad "sentinel dir must not live under /run, /var/run, /var/log or /home" "$SDIR" ;; +esac +grep -qE '^AB_ROOT_STATE_DIR="?\$' "$ENTRY" && _bad "AB_ROOT_STATE_DIR must be a literal, not inherited from the environment" +if [ -d /tmp ] && [ "$(stat -c '%u %a' /tmp)" = "0 1777" ]; then + _ok "/tmp on this host is root-owned 1777 (the sticky-bit guarantee holds)" +else + _skip "/tmp here is $(stat -c '%u %a' /tmp 2>/dev/null); the image's is root 1777" +fi + +# ── 7. prologue wiring ─────────────────────────────────────────────────────── +claim_line="$(grep -n '_ab_stage_b_claim "\$AB_ROOT_STATE_DIR"' "$ENTRY" | head -1 | cut -d: -f1)" +phase6_line="$(grep -n 'echo "\[6/8\]' "$ENTRY" | head -1 | cut -d: -f1)" +endA_line="$(grep -n '^fi # end STAGE_B_MODE=0 block' "$ENTRY" | head -1 | cut -d: -f1)" +if [ -n "$claim_line" ] && [ -n "$phase6_line" ] && [ -n "$endA_line" ] \ + && [ "$claim_line" -gt "$endA_line" ] && [ "$claim_line" -lt "$phase6_line" ]; then + _ok "Stage B claims at line ${claim_line}, before Phase 6 (line ${phase6_line})" +else + _bad "Stage B must claim the sentinel before Phase 6" "claim=${claim_line:-none} endA=${endA_line:-none} phase6=${phase6_line:-none}" +fi +prep_line="$(grep -n '_ab_root_state_dir_prepare "\$AB_ROOT_STATE_DIR"' "$ENTRY" | head -1 | cut -d: -f1)" +[ -n "$prep_line" ] && [ -n "$endA_line" ] && [ "$prep_line" -lt "$endA_line" ] \ + && _ok "Stage A prepares the state dir (line ${prep_line})" || _bad "Stage A must prepare the state dir" +if [ -n "$claim_line" ]; then + blk="$(sed -n "${claim_line},$((claim_line + 12))p" "$ENTRY")" + printf '%s' "$blk" | grep -qE '1\)[^;]*exit 0' && printf '%s' "$blk" | grep -qE '2\)|\*\)' \ + && _ok "a replay exits 0 (supervisord records a clean one-shot); an untrusted dir exits non-zero" \ + || _bad "replay must exit 0 and refusal must exit non-zero" "$blk" +fi + +_done diff --git a/tests/runtime-contract/RC-X1-03.sh b/tests/runtime-contract/RC-X1-03.sh new file mode 100755 index 000000000..1a560c244 --- /dev/null +++ b/tests/runtime-contract/RC-X1-03.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# RC-X1-03 — devuser is not in group root (custody X-1 step 1, W0). +# +# flake.nix seeded /etc/group with `root:x:0:devuser`, so every devuser process +# carried gid 0 and could read or write anything root left group-accessible. +# This is the one W0 change that needs an image rebuild to apply AND to undo. +# +# 1. static: the baked /etc/group heredoc in flake.nix lists no members for root +# 2. live (AGENTBOX_RC_LIVE=1, in a rebuilt image): `id -G devuser` lacks 0 +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FLAKE="${HERE}/../../flake.nix" +PASS=0; FAIL=0 +_ok() { PASS=$((PASS + 1)); printf 'ok %d - %s\n' "$((PASS + FAIL))" "$1"; } +_bad() { FAIL=$((FAIL + 1)); printf 'not ok %d - %s\n' "$((PASS + FAIL))" "$1"; [ -n "${2:-}" ] && printf '# %s\n' "$2"; } +_skip() { PASS=$((PASS + 1)); printf 'ok %d # SKIP %s\n' "$((PASS + FAIL))" "$1"; } + +GROUP="$(awk "/cat > \\\$out\/etc\/group <<'GROUP'/{f=1;next} f && /^[[:space:]]*GROUP\$/{exit} f{gsub(/^[[:space:]]+/,\"\"); print}" "$FLAKE")" +if [ -z "$GROUP" ]; then + _bad "baked /etc/group heredoc found in flake.nix" +else + root_line="$(printf '%s\n' "$GROUP" | grep '^root:' || true)" + members="${root_line##*:}" + [ "$root_line" = "root:x:0:" ] && _ok "baked group root has no supplementary members (${root_line})" \ + || _bad "baked group root must have no members" "${root_line:-missing} (members: ${members})" + printf '%s\n' "$GROUP" | grep -qE '^devuser:x:1000:' && _ok "devuser's primary group is still 1000" \ + || _bad "devuser:x:1000 must stay" +fi + +if [ "${AGENTBOX_RC_LIVE:-0}" = 1 ]; then + g="$(id -G devuser 2>/dev/null || echo error)" + case " $g " in + *" 0 "*) _bad "live: id -G devuser lacks 0" "got: $g" ;; + " error ") _bad "live: id -G devuser" "no devuser account here" ;; + *) _ok "live: id -G devuser = ${g} (no 0)" ;; + esac +else + _skip "live check needs the rebuilt image (AGENTBOX_RC_LIVE=1); running image: id -G devuser = $(id -G devuser 2>/dev/null || echo n/a)" +fi + +printf '1..%d\n# RC-X1-03: %d passed, %d failed\n' "$((PASS + FAIL))" "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/runtime-contract/RC-X1-04.sh b/tests/runtime-contract/RC-X1-04.sh new file mode 100755 index 000000000..8683bfe66 --- /dev/null +++ b/tests/runtime-contract/RC-X1-04.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# RC-X1-04 — the Docker socket is widened only while role isolation is off +# (custody X-1 step 1, W0, bypass 1). +# +# The entrypoint ran `chmod o+rw /var/run/docker.sock` on every boot, handing +# devuser the host's Docker daemon (root on the host). Under +# [security].role_isolation = true it must not; under false it must behave +# exactly as before. +# +# R2 (recorded in tests/runtime-contract/README.md, host-half contract): the +# socket is a bind of the HOST inode, already widened by earlier boots. Skipping +# the chmod does not narrow it; the boot reports `degraded:docker-socket` until +# the owner runs the host-side chmod (Q2). The entrypoint never narrows it itself. +# +# Asserts, against the real functions extracted from config/entrypoint-unified.sh: +# 1. flag off: a 0660 socket becomes 0666 (today's behaviour, same command) +# 2. flag on, socket 0660: untouched; state ok:docker-socket +# 3. flag on, socket already o+rw: untouched (host inode); state degraded + loud marker +# 4. the state write never follows a planted symlink +# 5. no socket: no-op +# 6. [security].role_isolation is read with the manifest reader and exported +# 7. live (AGENTBOX_RC_LIVE=1 with the flag on): devuser cannot use the raw socket +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRY="${HERE}/../../config/entrypoint-unified.sh" +PASS=0; FAIL=0 +_ok() { PASS=$((PASS + 1)); printf 'ok %d - %s\n' "$((PASS + FAIL))" "$1"; } +_bad() { FAIL=$((FAIL + 1)); printf 'not ok %d - %s\n' "$((PASS + FAIL))" "$1"; [ -n "${2:-}" ] && printf '# %s\n' "$2"; } +_skip() { PASS=$((PASS + 1)); printf 'ok %d # SKIP %s\n' "$((PASS + FAIL))" "$1"; } +_done() { printf '1..%d\n# RC-X1-04: %d passed, %d failed\n' "$((PASS + FAIL))" "$PASS" "$FAIL"; [ "$FAIL" -eq 0 ]; exit $?; } + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/rc-x1-04.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT +_extract() { awk -v n="$1" '$0 ~ "^"n"\\(\\) \\{" {f=1} f {print} f && /^}$/ {exit}' "$ENTRY"; } +for fn in _ab_toml_val _ab_toml_bool _ab_docker_socket_access; do + body="$(_extract "$fn")" + if [ -z "$body" ]; then _bad "${fn}() is defined in the entrypoint"; _done; fi + eval "$body" +done +_ok "_ab_docker_socket_access() and the manifest readers are defined" + +_mksock() { # _mksock + python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$1" 2>/dev/null \ + || node -e 'const s=require("net").createServer().listen(process.argv[1],()=>{process.exit(0)})' "$1" + chmod "$2" "$1" +} +_mode() { stat -c %a "$1"; } + +# ── 1. flag off ────────────────────────────────────────────────────────────── +S="${TMP}/off.sock"; _mksock "$S" 0660; ST="${TMP}/off.state" +_ab_docker_socket_access "$S" 0 "$ST" >/dev/null 2>&1 +[ "$(_mode "$S")" = 666 ] && _ok "flag off: socket widened o+rw exactly as before (0660 → 0666)" \ + || _bad "flag off must widen the socket" "mode $(_mode "$S")" +[ ! -e "$ST" ] && _ok "flag off: no role-isolation state is written" || _bad "flag off must write no state" +BODY="$(_extract _ab_docker_socket_access)" +printf '%s' "$BODY" | grep -qF 'chmod o+rw "$sock" 2>/dev/null || true' \ + && _ok "flag off runs the original command verbatim" || _bad "flag-off branch must keep the original chmod" + +# ── 2. flag on, socket not widened ─────────────────────────────────────────── +S="${TMP}/on.sock"; _mksock "$S" 0660; ST="${TMP}/on.state" +OUT="$(_ab_docker_socket_access "$S" 1 "$ST" 2>&1)" +[ "$(_mode "$S")" = 660 ] && _ok "flag on: socket mode untouched (0660)" || _bad "flag on must not chmod" "mode $(_mode "$S")" +[ "$(cat "$ST" 2>/dev/null)" = "ok:docker-socket" ] && _ok "flag on, narrow socket: state ok:docker-socket" \ + || _bad "state must read ok:docker-socket" "$(cat "$ST" 2>&1)" + +# ── 3. flag on, host inode already widened (R2) ────────────────────────────── +S="${TMP}/wide.sock"; _mksock "$S" 0666; ST="${TMP}/wide.state" +OUT="$(_ab_docker_socket_access "$S" 1 "$ST" 2>&1)" +[ "$(_mode "$S")" = 666 ] && _ok "flag on, widened socket: left alone (never chmods the host inode either way)" \ + || _bad "flag on must not chmod a widened socket" "mode $(_mode "$S")" +[ "$(cat "$ST" 2>/dev/null)" = "degraded:docker-socket" ] && _ok "flag on, widened socket: state degraded:docker-socket" \ + || _bad "state must read degraded:docker-socket" "$(cat "$ST" 2>&1)" +printf '%s' "$OUT" | grep -q 'ROLE-ISOLATION-DEGRADED docker-socket' && _ok "a grep-able ROLE-ISOLATION-DEGRADED marker is logged" \ + || _bad "degraded marker must be logged" "$OUT" + +# ── 4. symlinked state path ────────────────────────────────────────────────── +VICTIM="${TMP}/victim"; printf 'precious\n' >"$VICTIM"; ln -s "$VICTIM" "${TMP}/link.state" +_ab_docker_socket_access "$S" 1 "${TMP}/link.state" >/dev/null 2>&1 +[ "$(cat "$VICTIM")" = "precious" ] && _ok "the state write does not follow a planted symlink" \ + || _bad "state write followed a symlink" "$(cat "$VICTIM")" + +# ── 5. no socket ───────────────────────────────────────────────────────────── +_ab_docker_socket_access "${TMP}/absent.sock" 1 "${TMP}/absent.state" >/dev/null 2>&1; rc=$? +[ "$rc" = 0 ] && [ ! -e "${TMP}/absent.state" ] && _ok "no socket: returns 0, writes nothing" || _bad "absent socket must be a no-op" "rc=$rc" + +# ── 6. flag read + wiring ──────────────────────────────────────────────────── +printf '[security]\naudit_acknowledged = true\nrole_isolation = true\n' >"${TMP}/on.toml" +printf '[security]\naudit_acknowledged = true\n' >"${TMP}/off.toml" +a="$(AGENTBOX_CONFIG="${TMP}/on.toml" _ab_toml_bool security role_isolation)" +b="$(AGENTBOX_CONFIG="${TMP}/off.toml" _ab_toml_bool security role_isolation)" +[ "$a" = 1 ] && [ "$b" = 0 ] && _ok "role_isolation reads 1 when true, 0 when absent (default false)" || _bad "flag read" "on=$a off=$b" +grep -qE '^export AGENTBOX_ROLE_ISOLATION="\$\(_ab_toml_bool security role_isolation\)"' "$ENTRY" \ + && _ok "Stage A exports AGENTBOX_ROLE_ISOLATION from the manifest" || _bad "Stage A must export AGENTBOX_ROLE_ISOLATION" +grep -qE '^_ab_docker_socket_access /var/run/docker\.sock "\$AGENTBOX_ROLE_ISOLATION"' "$ENTRY" \ + && _ok "Stage A gates the socket on the flag" || _bad "Stage A must call _ab_docker_socket_access with the flag" +grep -nE '^\s*chmod o\+rw /var/run/docker\.sock' "$ENTRY" >/dev/null \ + && _bad "an unconditional chmod o+rw of the docker socket remains" || _ok "no unconditional chmod o+rw remains" + +# ── 7. live ────────────────────────────────────────────────────────────────── +if [ "${AGENTBOX_RC_LIVE:-0}" = 1 ] && [ "${AGENTBOX_ROLE_ISOLATION:-0}" = 1 ]; then + if [ "$(id -un)" != devuser ]; then + _bad "live check must run as devuser" "running as $(id -un)" + elif DOCKER_HOST=unix:///var/run/docker.sock timeout 10 docker version >/dev/null 2>&1; then + _bad "live: devuser can still drive the raw Docker socket" "see R2: host-side chmod 0660 needed" + else + _ok "live: devuser docker version on the raw socket fails" + fi +else + _skip "live check needs the flag on in a rebuilt image (AGENTBOX_RC_LIVE=1)" +fi + +_done diff --git a/tests/runtime-contract/RC-X1-05.node-test.cjs b/tests/runtime-contract/RC-X1-05.node-test.cjs new file mode 100644 index 000000000..d74e0036a --- /dev/null +++ b/tests/runtime-contract/RC-X1-05.node-test.cjs @@ -0,0 +1,187 @@ +'use strict'; +// RC-X1-05 — read-only Docker proxy for devuser under [security].role_isolation +// (custody X-1 step 1, W0, owner question Q1 default). +// +// With the flag on, devuser loses the raw host socket and gets +// config/docker-read-proxy.cjs: GET/HEAD on /_ping, /version, /info, +// /containers/json, /containers//json and /containers//logs; 403 for +// everything else, including the read-shaped exfiltration routes (archive, export). +// +// 1. isAllowed() against every fixture +// 2. end to end through a fake upstream: allowed requests arrive unchanged, +// denied requests get 403 and never reach the upstream +// 3. Upgrade requests are refused even on allowed paths +// 4. the program exits 0 without listening when the flag is off +// 5. the real `docker` CLI via DOCKER_HOST: ps, inspect and logs work; exec fails +// +// Run: node --test tests/runtime-contract/RC-X1-05.node-test.cjs +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const { spawn, execFile } = require('node:child_process'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const PROXY = path.join(ROOT, 'config', 'docker-read-proxy.cjs'); +const FIXTURES = require('./fixtures/docker-read-proxy-allowlist.json').cases; + +const tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'rc-x1-05-')); +process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true })); + +// ── fake Docker daemon on a unix socket ────────────────────────────────────── +function startFakeDaemon(sock) { + const seen = []; + const server = http.createServer((req, res) => { + seen.push({ method: req.method, url: req.url, upgrade: req.headers.upgrade }); + const p = req.url.split('?')[0].replace(/^\/v1\.\d+/, ''); + const hdr = { 'API-Version': '1.47', 'Docker-Experimental': 'false', 'OSType': 'linux', 'Content-Type': 'application/json' }; + if (p === '/_ping') { res.writeHead(200, { ...hdr, 'Content-Type': 'text/plain' }); return res.end(req.method === 'HEAD' ? undefined : 'OK'); } + if (p === '/version') { res.writeHead(200, hdr); return res.end(JSON.stringify({ Version: '29.0.0-fake', ApiVersion: '1.47', MinAPIVersion: '1.24', Os: 'linux', Arch: 'amd64' })); } + if (p === '/info') { res.writeHead(200, hdr); return res.end(JSON.stringify({ ID: 'fake', Containers: 1 })); } + if (p === '/containers/json') { + res.writeHead(200, hdr); + return res.end(JSON.stringify([{ Id: 'c0ffee000000', Names: ['/fakebox'], Image: 'fake:latest', Command: 'sleep', Created: 0, State: 'running', Status: 'Up', Ports: [], Labels: {}, Mounts: [] }])); + } + let m = /^\/containers\/([^/]+)\/json$/.exec(p); + if (m) { + res.writeHead(200, hdr); + return res.end(JSON.stringify({ Id: 'c0ffee000000', Name: '/' + m[1], State: { Status: 'running', Running: true }, Config: { Tty: true, Image: 'fake:latest' }, HostConfig: {}, Mounts: [], NetworkSettings: {} })); + } + m = /^\/containers\/([^/]+)\/logs$/.exec(p); + if (m) { res.writeHead(200, { ...hdr, 'Content-Type': 'application/vnd.docker.raw-stream' }); return res.end('fake-log-line\n'); } + res.writeHead(200, hdr); res.end('{}'); // anything else would "succeed" — so a leak is visible + }); + return new Promise((resolve) => server.listen(sock, () => resolve({ server, seen }))); +} + +function request(sock, method, url, headers = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ socketPath: sock, method, path: url, headers }, (res) => { + let body = ''; + res.on('data', (d) => { body += d; }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.on('error', reject); + req.end(); + }); +} + +function startProxy(env) { + const child = spawn(process.execPath, [PROXY], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (d) => { out += d; }); + child.stderr.on('data', (d) => { out += d; }); + return { child, log: () => out }; +} + +async function waitFor(file, ms = 5000) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + if (fs.existsSync(file)) return true; + await new Promise((r) => setTimeout(r, 25)); + } + return false; +} + +test('proxy module exists and exports isAllowed', () => { + assert.ok(fs.existsSync(PROXY), `${PROXY} must exist`); + const mod = require(PROXY); + assert.equal(typeof mod.isAllowed, 'function'); + assert.equal(typeof mod.createProxy, 'function'); +}); + +test('isAllowed matches every fixture', () => { + const { isAllowed } = require(PROXY); + for (const c of FIXTURES) { + assert.equal(isAllowed(c.method, c.path), c.allow, `${c.method} ${c.path} (${c.why})`); + } +}); + +test('end to end: allowed requests forwarded unchanged, denied ones never reach the upstream', async (t) => { + const up = path.join(tmp, 'up.sock'); + const listen = path.join(tmp, 'ro.sock'); + const { server, seen } = await startFakeDaemon(up); + t.after(() => server.close()); + const p = startProxy({ PATH: process.env.PATH, AGENTBOX_ROLE_ISOLATION: '1', DOCKER_READ_PROXY_UPSTREAM: up, DOCKER_READ_PROXY_LISTEN: listen }); + t.after(() => p.child.kill('SIGKILL')); + assert.ok(await waitFor(listen), `proxy must listen on ${listen}\n${p.log()}`); + assert.equal((fs.statSync(listen).mode & 0o777), 0o666, 'listen socket is 0666 (set by umask at bind, no chmod)'); + + for (const c of FIXTURES) { + if (/^[a-z]+:/.test(c.path)) continue; // absolute-form cannot be sent over a unix socket client faithfully + const before = seen.length; + const r = await request(listen, c.method, c.path); + if (c.allow) { + assert.notEqual(r.status, 403, `${c.method} ${c.path} must pass (${c.why})`); + assert.equal(seen.length, before + 1, `${c.method} ${c.path} must reach the upstream`); + assert.equal(seen[seen.length - 1].url, c.path, 'path and query forwarded verbatim'); + assert.equal(seen[seen.length - 1].method, c.method); + } else { + assert.equal(r.status, 403, `${c.method} ${c.path} must be 403 (${c.why})`); + assert.equal(seen.length, before, `${c.method} ${c.path} must NOT reach the upstream`); + if (c.method !== 'HEAD') assert.match(r.body, /read-only/); + } + } +}); + +test('Upgrade requests are refused even on an allowed path', async (t) => { + const up = path.join(tmp, 'up2.sock'); + const listen = path.join(tmp, 'ro2.sock'); + const { server, seen } = await startFakeDaemon(up); + t.after(() => server.close()); + const p = startProxy({ PATH: process.env.PATH, AGENTBOX_ROLE_ISOLATION: '1', DOCKER_READ_PROXY_UPSTREAM: up, DOCKER_READ_PROXY_LISTEN: listen }); + t.after(() => p.child.kill('SIGKILL')); + assert.ok(await waitFor(listen)); + const status = await new Promise((resolve) => { + const req = http.request({ socketPath: listen, method: 'GET', path: '/containers/x/logs?follow=1', headers: { Connection: 'Upgrade', Upgrade: 'tcp' } }); + req.on('response', (res) => { res.resume(); resolve(res.statusCode); }); + req.on('upgrade', () => resolve(101)); + req.on('error', () => resolve('closed')); + req.end(); + }); + assert.notEqual(status, 101, 'no protocol upgrade through the proxy'); + assert.equal(seen.length, 0, 'the upgrade request never reached the upstream'); +}); + +test('flag off: exits 0 and never listens', async () => { + const listen = path.join(tmp, 'off.sock'); + const p = startProxy({ PATH: process.env.PATH, AGENTBOX_ROLE_ISOLATION: '0', DOCKER_READ_PROXY_UPSTREAM: path.join(tmp, 'nope.sock'), DOCKER_READ_PROXY_LISTEN: listen }); + const code = await new Promise((r) => p.child.on('exit', r)); + assert.equal(code, 0); + assert.equal(fs.existsSync(listen), false); +}); + +test('the docker CLI works for ps/inspect/logs and fails for exec', async (t) => { + const docker = (process.env.PATH || '').split(':').map((d) => path.join(d, 'docker')).find((f) => fs.existsSync(f)); + if (!docker) { t.skip('no docker CLI on PATH'); return; } + const up = path.join(tmp, 'up3.sock'); + const listen = path.join(tmp, 'ro3.sock'); + const { server, seen } = await startFakeDaemon(up); + t.after(() => server.close()); + const p = startProxy({ PATH: process.env.PATH, AGENTBOX_ROLE_ISOLATION: '1', DOCKER_READ_PROXY_UPSTREAM: up, DOCKER_READ_PROXY_LISTEN: listen }); + t.after(() => p.child.kill('SIGKILL')); + assert.ok(await waitFor(listen)); + const cfg = fs.mkdtempSync(path.join(tmp, 'dockercfg-')); + const env = { PATH: process.env.PATH, HOME: cfg, DOCKER_CONFIG: cfg, DOCKER_HOST: `unix://${listen}` }; + const run = (args) => new Promise((resolve) => execFile(docker, args, { env, timeout: 20000 }, (err, stdout, stderr) => resolve({ code: err ? (err.code ?? 1) : 0, stdout, stderr }))); + + const ps = await run(['ps']); + assert.equal(ps.code, 0, `docker ps: ${ps.stderr}`); + assert.match(ps.stdout, /fakebox/); + const insp = await run(['inspect', '--type', 'container', 'fakebox']); + assert.equal(insp.code, 0, `docker inspect: ${insp.stderr}`); + assert.match(insp.stdout, /c0ffee000000/); + const logs = await run(['logs', 'fakebox']); + assert.equal(logs.code, 0, `docker logs: ${logs.stderr}`); + assert.match(logs.stdout, /fake-log-line/); + + const before = seen.filter((s) => s.method !== 'GET' && s.method !== 'HEAD').length; + const ex = await run(['exec', 'fakebox', 'true']); + assert.notEqual(ex.code, 0, 'docker exec must fail through the proxy'); + assert.match(ex.stderr, /read-only/); + const run2 = await run(['run', '--rm', 'fake:latest', 'true']); + assert.notEqual(run2.code, 0, 'docker run must fail through the proxy'); + assert.equal(seen.filter((s) => s.method !== 'GET' && s.method !== 'HEAD').length, before, 'no mutating request reached the daemon'); +}); diff --git a/tests/runtime-contract/fixtures/docker-read-proxy-allowlist.json b/tests/runtime-contract/fixtures/docker-read-proxy-allowlist.json new file mode 100644 index 000000000..1ef5447a2 --- /dev/null +++ b/tests/runtime-contract/fixtures/docker-read-proxy-allowlist.json @@ -0,0 +1,44 @@ +{ + "comment": "RC-X1-05 fixtures for config/docker-read-proxy.cjs. allow=true cases must reach the upstream unchanged; allow=false cases must get 403 and never reach it.", + "cases": [ + { "method": "GET", "path": "/_ping", "allow": true, "why": "CLI version negotiation" }, + { "method": "HEAD", "path": "/_ping", "allow": true, "why": "CLI version negotiation (HEAD first)" }, + { "method": "GET", "path": "/version", "allow": true, "why": "docker version" }, + { "method": "GET", "path": "/v1.47/version", "allow": true, "why": "versioned prefix" }, + { "method": "GET", "path": "/info", "allow": true, "why": "docker info" }, + { "method": "GET", "path": "/v1.47/containers/json?all=1", "allow": true, "why": "docker ps -a" }, + { "method": "GET", "path": "/containers/json?filters=%7B%22name%22%3A%7B%22agentbox%22%3Atrue%7D%7D", "allow": true, "why": "docker ps --filter" }, + { "method": "GET", "path": "/v1.47/containers/agentbox/json", "allow": true, "why": "docker inspect" }, + { "method": "GET", "path": "/containers/3f2a9c1b7e4d/json?size=1", "allow": true, "why": "docker inspect -s" }, + { "method": "GET", "path": "/v1.47/containers/visionflow_container/logs?stdout=1&stderr=1&tail=100", "allow": true, "why": "docker logs" }, + { "method": "GET", "path": "/containers/a.b-c_d/logs?follow=1&stdout=1", "allow": true, "why": "docker logs -f, name charset" }, + + { "method": "POST", "path": "/v1.47/containers/agentbox/exec", "allow": false, "why": "docker exec" }, + { "method": "POST", "path": "/exec/abc/start", "allow": false, "why": "exec start" }, + { "method": "POST", "path": "/containers/create", "allow": false, "why": "docker run / create" }, + { "method": "POST", "path": "/containers/agentbox/start", "allow": false, "why": "start" }, + { "method": "POST", "path": "/containers/agentbox/kill", "allow": false, "why": "kill" }, + { "method": "DELETE", "path": "/containers/agentbox", "allow": false, "why": "rm" }, + { "method": "PUT", "path": "/containers/agentbox/archive?path=/", "allow": false, "why": "docker cp into" }, + { "method": "GET", "path": "/containers/agentbox/archive?path=/run/secrets", "allow": false, "why": "docker cp out: file exfiltration is a read" }, + { "method": "HEAD", "path": "/containers/agentbox/archive?path=/", "allow": false, "why": "archive stat" }, + { "method": "GET", "path": "/containers/agentbox/export", "allow": false, "why": "full filesystem export is a read" }, + { "method": "GET", "path": "/containers/agentbox/attach/ws?stream=1", "allow": false, "why": "attach websocket" }, + { "method": "GET", "path": "/containers/agentbox/top", "allow": false, "why": "not on the allowlist" }, + { "method": "GET", "path": "/containers/agentbox/stats", "allow": false, "why": "not on the allowlist" }, + { "method": "GET", "path": "/images/json", "allow": false, "why": "not on the allowlist" }, + { "method": "GET", "path": "/volumes", "allow": false, "why": "not on the allowlist" }, + { "method": "GET", "path": "/secrets", "allow": false, "why": "swarm secrets" }, + { "method": "POST", "path": "/build", "allow": false, "why": "build" }, + { "method": "GET", "path": "/containers/../exec/abc/json", "allow": false, "why": "dot segments" }, + { "method": "GET", "path": "/containers/%2e%2e/exec/abc/json", "allow": false, "why": "encoded dot segments" }, + { "method": "GET", "path": "/containers/agentbox%2Farchive/json", "allow": false, "why": "encoded slash" }, + { "method": "GET", "path": "/containers/agentbox/json/../archive", "allow": false, "why": "trailing traversal" }, + { "method": "GET", "path": "//containers/json", "allow": false, "why": "double slash" }, + { "method": "GET", "path": "/v1.47/v1.47/containers/json", "allow": false, "why": "double version prefix" }, + { "method": "GET", "path": "/containers/json#frag", "allow": false, "why": "fragment" }, + { "method": "GET", "path": "/containers/-x/json", "allow": false, "why": "name must start alphanumeric" }, + { "method": "GET", "path": "http://docker/containers/json", "allow": false, "why": "absolute-form target" }, + { "method": "OPTIONS","path": "/_ping", "allow": false, "why": "method" } + ] +} From 35f5466d049f5435ed350da8795406dbee101568 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 08:59:16 +0000 Subject: [PATCH 05/58] test(pod-bridge): pin the live-mirror child key to the JS derivation (G-5) Adds `mirror_key`, the Rust side of the phone-key contract in config/hooks/nostr-live-mirror.cjs:200-221: child_sk = HMAC-SHA256(operator_sk, tag), tag defaulting to "agentbox-mirror-v1". RustCrypto hmac 0.12 over sha2 (hmac 0.12.1 was already locked via nostr-bbs-core, so the lock gains one edge and no crate). The JS is unchanged; the phone key does not move. tests/mirror_key.rs: 7 known-answer tests on synthetic keys, expected values agreed by Node crypto, Python hmac and @noble/curves. The aaaa vector's child pubkey is the existing CHILD_RECIPIENT fixture in tests/sovereign/egress-boundary.test.js. Scalar 6 (odd y) pins the hazard: the HMAC must take the env hex as supplied, not the even-y negated scalar identity.rs persists, or an odd-y house key would hand the phone a different child. Cargo.lock: hand-applied single edge. A local cargo resolve also bumps solid-pod-rs/nostr-bbs path deps to the sibling checkouts' versions; that drift is pre-existing (--locked fails on main too) and is not carried, because lib/nostr-pod-bridge.nix fetches the pinned revs. Co-Authored-By: jjohare --- services/nostr-pod-bridge/Cargo.lock | 1 + services/nostr-pod-bridge/Cargo.toml | 3 + services/nostr-pod-bridge/src/lib.rs | 1 + services/nostr-pod-bridge/src/mirror_key.rs | 81 +++++++++++++ services/nostr-pod-bridge/tests/mirror_key.rs | 109 ++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 services/nostr-pod-bridge/src/mirror_key.rs create mode 100644 services/nostr-pod-bridge/tests/mirror_key.rs diff --git a/services/nostr-pod-bridge/Cargo.lock b/services/nostr-pod-bridge/Cargo.lock index d2dd776be..1736ca4db 100644 --- a/services/nostr-pod-bridge/Cargo.lock +++ b/services/nostr-pod-bridge/Cargo.lock @@ -1093,6 +1093,7 @@ dependencies = [ "bech32 0.12.0", "futures-util", "hex", + "hmac", "k256", "nostr-bbs-core", "reqwest", diff --git a/services/nostr-pod-bridge/Cargo.toml b/services/nostr-pod-bridge/Cargo.toml index 687ad0de5..d9bc60064 100644 --- a/services/nostr-pod-bridge/Cargo.toml +++ b/services/nostr-pod-bridge/Cargo.toml @@ -36,6 +36,9 @@ serde_json = { version = "1", features = ["preserve_order"] } hex = "0.4" # SHA-256 for the urn:agentbox:activity content address (ADR-013 minter parity). sha2 = "0.10" +# HMAC-SHA256 for the live-mirror child key (`mirror_key`): RustCrypto, already +# in the lock graph at 0.12.1 via nostr-bbs-core, so this adds no new crate. +hmac = "0.12" # secp256k1 scalar/point arithmetic for the BIP-340 even-y normalisation and the # 64-byte SEC1 (X || Y) `public_key_hex` the identity file records. Signing and # every NIP-19 encoding still go through nostr-bbs-core; this is encoding only. diff --git a/services/nostr-pod-bridge/src/lib.rs b/services/nostr-pod-bridge/src/lib.rs index cabb5fe1d..05a4b0839 100644 --- a/services/nostr-pod-bridge/src/lib.rs +++ b/services/nostr-pod-bridge/src/lib.rs @@ -68,6 +68,7 @@ pub mod contract; pub mod egress_policy; pub mod envmap; pub mod identity; +pub mod mirror_key; pub mod pyjson; pub mod session_summary; diff --git a/services/nostr-pod-bridge/src/mirror_key.rs b/services/nostr-pod-bridge/src/mirror_key.rs new file mode 100644 index 000000000..9aeaad561 --- /dev/null +++ b/services/nostr-pod-bridge/src/mirror_key.rs @@ -0,0 +1,81 @@ +//! The live-mirror child key: the one key the operator's phone holds. +//! +//! `config/hooks/nostr-live-mirror.cjs` (`deriveChildKey`, lines 200-221) and +//! `config/nostr-gateway/nostr-send.cjs` derive a single-purpose child from +//! the container identity key: +//! +//! ```text +//! child_sk = HMAC-SHA256(key = operator_sk_bytes, msg = tag_utf8) +//! tag = $AGENTBOX_MIRROR_KEY_TAG, default "agentbox-mirror-v1" +//! ``` +//! +//! The phone (Amethyst/Amber) imports `child_sk`, so this derivation is a +//! compatibility contract: if the Rust identity port computed a different +//! value, the phone would silently lose the mirror. This module is the Rust +//! side of that contract. It changes nothing in the JavaScript; the +//! known-answer tests in `tests/mirror_key.rs` pin both to the same bytes. +//! +//! ## The HMAC key is the operator hex *as supplied* +//! +//! The JavaScript keys the HMAC with the raw `AGENTBOX_PRIVKEY_HEX` bytes. +//! [`crate::identity`] canonicalises a secret to BIP-340 even-y and persists +//! the *negated* scalar when the supplied one has odd y. For such a key the +//! identity file's `private_key_hex` differs from the env value, and keying the +//! HMAC with it would yield a different child, so a different phone key. +//! [`child_secret`] therefore takes the operator hex exactly as the env +//! carries it and must never be fed the normalised scalar. +//! +//! No primitive is implemented here: HMAC is the RustCrypto `hmac` crate over +//! `sha2::Sha256`, and the public half goes through +//! [`crate::identity::keypair_from_privkey_hex`] (`k256`). + +use anyhow::{anyhow, Context, Result}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +/// Domain-separation tag used when `AGENTBOX_MIRROR_KEY_TAG` is unset or empty. +/// Bumping it rotates the phone key; it is the same literal as the JavaScript. +pub const DEFAULT_TAG: &str = "agentbox-mirror-v1"; + +/// Derive the 32-byte mirror child secret from the operator key. +/// +/// `operator_sk_hex` is the 64-hex secret exactly as `AGENTBOX_PRIVKEY_HEX` +/// supplies it, in either case, before any even-y normalisation (see the +/// module docs). `tag` of `None` or an empty or whitespace string selects +/// [`DEFAULT_TAG`], matching `envFirst(...) || 'agentbox-mirror-v1'`. +/// +/// The result is not reduced modulo the curve order, as in the JavaScript. A +/// digest at or above `n` (probability about 2^-128) is rejected later by +/// [`child_xonly_pubkey_hex`] rather than altered here. +/// +/// ``` +/// use nostr_pod_bridge::mirror_key::child_secret; +/// // Synthetic vector shared with tests/sovereign/egress-boundary.test.js. +/// let child = child_secret(&"a".repeat(64), None).unwrap(); +/// assert_eq!( +/// hex::encode(child), +/// "a25935d1bb6782faa0ceaca1dce34bb23bf45a507e8e19b8973de836cbdcab72" +/// ); +/// ``` +pub fn child_secret(operator_sk_hex: &str, tag: Option<&str>) -> Result<[u8; 32]> { + let raw = operator_sk_hex.trim(); + if raw.len() != 64 || !raw.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(anyhow!("operator key must be exactly 64 hex characters")); + } + let key = hex::decode(raw).context("operator key is not valid hex")?; + let tag = match tag.map(str::trim) { + Some(t) if !t.is_empty() => t, + _ => DEFAULT_TAG, + }; + let mut mac = as Mac>::new_from_slice(&key) + .map_err(|_| anyhow!("HMAC key rejected"))?; + mac.update(tag.as_bytes()); + Ok(mac.finalize().into_bytes().into()) +} + +/// The BIP-340 x-only public key of the mirror child: the identity the phone +/// shows and the recipient the hook self-DMs. +pub fn child_xonly_pubkey_hex(operator_sk_hex: &str, tag: Option<&str>) -> Result { + let child = child_secret(operator_sk_hex, tag)?; + Ok(crate::identity::keypair_from_privkey_hex(&hex::encode(child))?.x_only_pubkey_hex) +} diff --git a/services/nostr-pod-bridge/tests/mirror_key.rs b/services/nostr-pod-bridge/tests/mirror_key.rs new file mode 100644 index 000000000..50cf96174 --- /dev/null +++ b/services/nostr-pod-bridge/tests/mirror_key.rs @@ -0,0 +1,109 @@ +//! Known-answer tests: the Rust mirror-child derivation equals +//! `config/hooks/nostr-live-mirror.cjs:200-221` byte for byte. +//! +//! Every expected value was computed three ways and agreed: +//! Node `crypto.createHmac('sha256', Buffer.from(sk, 'hex')).update(tag)` (the +//! hook's own code), Python `hmac.new(..., hashlib.sha256)`, and, for the +//! public halves, `@noble/curves` secp256k1. All secrets are synthetic. + +use nostr_pod_bridge::identity::keypair_from_privkey_hex; +use nostr_pod_bridge::mirror_key::{child_secret, child_xonly_pubkey_hex, DEFAULT_TAG}; + +const SK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SK_1: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +/// Scalar 6: its public point has ODD y, so `identity` persists `n - 6`. +const SK_6: &str = "0000000000000000000000000000000000000000000000000000000000000006"; + +/// (operator sk, tag, child_sk, child x-only pubkey) +const VECTORS: &[(&str, &str, &str, &str)] = &[ + ( + SK_A, + "agentbox-mirror-v1", + "a25935d1bb6782faa0ceaca1dce34bb23bf45a507e8e19b8973de836cbdcab72", + // == CHILD_RECIPIENT in tests/sovereign/egress-boundary.test.js + "175704cfcc83cb41eb05259fe87888b54dfb48a5c98e3d04cad7f9865a07a33a", + ), + ( + SK_1, + "agentbox-mirror-v1", + "8994e826d6a81c6948f07435a64c3980058a5222a671eddf7eb86df24317ed90", + "debff6f0a565f739eca2d9f16713293a6defee7520155ca778bb2d5885fbf5ee", + ), + ( + SK_6, + "agentbox-mirror-v1", + "552675162b3174ac842e4e05c552d864c6309fe0f226235dd8ed50f6b23650ad", + "6bc396e3a4fefb4d49f668a7729ef216605d3d74ed7d59da6fe256b233f4cbc9", + ), +]; + +#[test] +fn child_secret_matches_the_javascript_byte_for_byte() { + for (sk, tag, want_sk, _) in VECTORS { + let got = child_secret(sk, Some(tag)).unwrap(); + assert_eq!(hex::encode(got), *want_sk, "child_sk for sk=…{}", &sk[60..]); + } +} + +#[test] +fn child_pubkey_matches_the_javascript_fixture() { + for (sk, tag, _, want_pub) in VECTORS { + assert_eq!(child_xonly_pubkey_hex(sk, Some(tag)).unwrap(), *want_pub); + } +} + +#[test] +fn absent_or_blank_tag_is_the_default_tag() { + assert_eq!(DEFAULT_TAG, "agentbox-mirror-v1"); + let want = child_secret(SK_A, Some(DEFAULT_TAG)).unwrap(); + assert_eq!(child_secret(SK_A, None).unwrap(), want); + assert_eq!(child_secret(SK_A, Some("")).unwrap(), want); + assert_eq!(child_secret(SK_A, Some(" ")).unwrap(), want); +} + +#[test] +fn a_custom_tag_rotates_the_child() { + // AGENTBOX_MIRROR_KEY_TAG=agentbox-mirror-v2, same three-way agreement. + let v2 = child_secret(SK_A, Some("agentbox-mirror-v2")).unwrap(); + assert_eq!( + hex::encode(v2), + "aa4502a0d54eb3923b929f13e9d6b1320e09f498e51b9cf28247e619e97c9b19" + ); + let v2_1 = child_secret(SK_1, Some("agentbox-mirror-v2")).unwrap(); + assert_eq!( + hex::encode(v2_1), + "8a25c28746ae28c80fe3009e301695c5cd253d51ea2561220989113e4e4422e7" + ); +} + +#[test] +fn hex_case_does_not_change_the_child() { + // The hook accepts /^[0-9a-f]{64}$/i and Buffer.from(hex) is case-blind. + assert_eq!( + child_secret(&SK_A.to_uppercase(), None).unwrap(), + child_secret(SK_A, None).unwrap() + ); +} + +/// The hazard this module documents: keying on the even-y-normalised scalar +/// that `identity` persists would hand the phone a different key. +#[test] +fn odd_y_key_must_use_the_raw_env_hex_not_the_normalised_scalar() { + let normalised = keypair_from_privkey_hex(SK_6).unwrap().private_key_hex; + assert_ne!(normalised, SK_6, "scalar 6 must have odd y for this test to mean anything"); + assert_ne!( + child_secret(&normalised, None).unwrap(), + child_secret(SK_6, None).unwrap(), + ); + assert_eq!( + hex::encode(child_secret(SK_6, None).unwrap()), + "552675162b3174ac842e4e05c552d864c6309fe0f226235dd8ed50f6b23650ad" + ); +} + +#[test] +fn malformed_operator_keys_are_refused() { + for bad in ["", "abc", &"a".repeat(63), &"a".repeat(65), &"g".repeat(64)] { + assert!(child_secret(bad, None).is_err(), "accepted {bad:?}"); + } +} From d3ff8e9a876e6b026b543f8824487e002e04cfa0 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 08:59:16 +0000 Subject: [PATCH 06/58] docs(config): b4165401 is the operator's decision signer, not visionclaw-server (Q15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q15 by evidence, 2026-10-03, read from the dreamlab relay (every stored event of kinds 31400-31403, 21 in total) and VisionClaw source, without touching any .env: - VisionClaw signs governance with ACSP_PANEL_NOSTR_PRIVKEY, else VISIONCLAW_NOSTR_PRIVKEY (elevation_actor.rs:199-200, decision_elevation_actor.rs:183-184, app_state.rs:1362-1363). - The only VisionClaw-shaped 31402s (acsp/events.rs tag set: priority, category, subject-kind) are signed by 11ed6422…663c (2026-08-11). - b4165401…2f7a signs only 31403 decisions, as the responder; it is [sovereign_mesh.operator].pubkey_hex, the operator's NIP-07 key. So the comments naming b4165401 "visionclaw-server governance publisher" were wrong, and VisionClaw still shares the house key: a K_broker is needed. Comment-only change; goldens are frozen fixtures and stay as captured. Co-Authored-By: jjohare --- agentbox.toml | 6 +++--- setup/agentbox.default.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/agentbox.toml b/agentbox.toml index b06199af4..5b77b0e9b 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 diff --git a/setup/agentbox.default.toml b/setup/agentbox.default.toml index 573f6f994..ffafada5c 100644 --- a/setup/agentbox.default.toml +++ b/setup/agentbox.default.toml @@ -168,7 +168,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 From 710ec7b84053b63b58df1d22f42b3eaf4ac39d1e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 09:00:20 +0000 Subject: [PATCH 07/58] docs(adr): re-verify nine records at d3ff8e9a8 (G-5 Q15 comment correction, mirror KAT dep) ADR-2012, 2019, 2020, 2023, 2028, 2029, 2033 and 2105 were tripped by comment-only changes to agentbox.toml (and setup/agentbox.default.toml for 2028/2029). ADR-2030 was tripped by nostr-pod-bridge gaining hmac 0.12, which is MIT OR Apache-2.0 and was already in the lock; the crate licence is unchanged and the licence gate passes. ADR-2012's note corrects the label at its :157-160 (b4165401 is the operator's 31403 decision signer) and confirms that the key split has not landed, so its review_trigger has not fired. Index check leaves only ADR-2103's pre-existing staleness, from run-producer.sh. That is also present on origin/main and is not this branch's. Co-Authored-By: jjohare --- docs/adr/ADR-2012-relay-allowlist-only-ingress.md | 6 +++++- docs/adr/ADR-2019-model-lifecycle-384-dim-freeze.md | 6 +++++- docs/adr/ADR-2020-capability-gating.md | 6 +++++- docs/adr/ADR-2023-loom-facade.md | 6 +++++- docs/adr/ADR-2028-vault-manifest-path-authority.md | 6 +++++- docs/adr/ADR-2029-rune-markdown-tui-notes-window.md | 6 +++++- ...0-permissive-licensing-for-publishable-service-crates.md | 6 +++++- docs/adr/ADR-2033-deepsec-security-gate.md | 6 +++++- .../ADR-2105-agentbox-kind-bands-and-the-colloquy-move.md | 6 +++++- 9 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/adr/ADR-2012-relay-allowlist-only-ingress.md b/docs/adr/ADR-2012-relay-allowlist-only-ingress.md index 1abaa1820..c4b7a25cb 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. 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..f399a0e6e 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. diff --git a/docs/adr/ADR-2020-capability-gating.md b/docs/adr/ADR-2020-capability-gating.md index cbcacdc30..a972fa2df 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. diff --git a/docs/adr/ADR-2023-loom-facade.md b/docs/adr/ADR-2023-loom-facade.md index 8b9af3110..5ff91f931 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. diff --git a/docs/adr/ADR-2028-vault-manifest-path-authority.md b/docs/adr/ADR-2028-vault-manifest-path-authority.md index 71d3d2720..56850c3a1 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. 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..fd377cce9 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. 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..651678ac7 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. diff --git a/docs/adr/ADR-2033-deepsec-security-gate.md b/docs/adr/ADR-2033-deepsec-security-gate.md index 59a9e8da4..41c3be247 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. 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..6d8f7450c 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: d3ff8e9a876e6b026b543f8824487e002e04cfa0 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,7 @@ 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 (`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. From bf5dc34fd2536950d5c4180d8a626f88e2ba632a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 09:00:50 +0000 Subject: [PATCH 08/58] =?UTF-8?q?docs(closeout):=20G-5=20key-split=20owner?= =?UTF-8?q?=20handoff=20=E2=80=94=20Q15=20answered,=20minting=20held=20on?= =?UTF-8?q?=20D1-D3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: jjohare --- docs/estate-closeout/g5-key-split-handoff.md | 79 ++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/estate-closeout/g5-key-split-handoff.md 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..9f048e6eb --- /dev/null +++ b/docs/estate-closeout/g5-key-split-handoff.md @@ -0,0 +1,79 @@ +# 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 governance requests with, and the identity the +browser sidecar logs in as. Its private half sits in three places: agentbox `.env`, VisionClaw +`.env` and the browsercontainer Podkey vault. Anyone who gets any one of those copies can do +every one of those jobs. The split keeps `11ed6422…` as the container identity and gives each +other job its own key, so that the two copies outside agentbox can be deleted. + +## Done on this branch (no behaviour changes) + +1. **Q15 is answered: VisionClaw signs with the house key `11ed6422…663c`.** Evidence, read + without opening any `.env`: + - VisionClaw takes its governance signer from `ACSP_PANEL_NOSTR_PRIVKEY`, falling back to + `VISIONCLAW_NOSTR_PRIVKEY` (`src/actors/elevation_actor.rs:199-200`, + `decision_elevation_actor.rs:183-184`, `app_state.rs:1362-1363`). + - The dreamlab relay holds 21 events of kinds 31400-31403. The VisionClaw-shaped 31402s + carry the `priority`/`category`/`subject-kind` tags that `src/services/acsp/events.rs` + writes, and they are signed by `11ed6422…663c`. The latest is dated 2026-08-11. + - `b4165401…2f7a` signs only 31403 *decisions*, as the responder. It is your operator + NIP-07 key (`[sovereign_mesh.operator]`). The `agentbox.toml` comments that called it the + "visionclaw-server governance publisher" were wrong and are corrected. + - The rest of the governance traffic is JunkieJarvis (`2de44d56…16e9`), which is not part + of G-5. + + So VisionClaw needs a key of its own (K_broker). +2. **The phone mirror key is pinned.** `services/nostr-pod-bridge/src/mirror_key.rs` contains + the Rust version of the derivation in `nostr-live-mirror.cjs:200-221`. Seven known-answer tests + prove that it gives byte-for-byte the same result. The phone key does not change. One trap is + now caught by a test: the derivation must use the hex exactly as `.env` holds it, not the + even-y-normalised copy the identity file stores. For roughly half of all keys those two differ. +3. **Zone migrator: already unused, nothing to delete.** In the forum repo, + `nostr-bbs-zone-migrate` reads its key from the environment each time it runs. It holds no + key and runs no service, and nothing in agentbox calls it. To retire it, withdraw + `11ed6422…`'s admin role at the forum (step E below). Keep the tool: re-sealing history (Q16) + would need it. + +## Held: three decisions before any key is minted + +Nothing has been minted. Minting is quick once these three are settled. + +- **D1. Where are the backups encrypted to?** The repo records no operator age recipient + (`age1…`). Without one, the age backup can only be keyed by something that also lives in the + container, and that protects nothing. Please supply a public `age1…` recipient. Its private + identity stays with you. To make the restore test meaningful, it will also use a second + throw-away recipient that is destroyed after the check. +- **D2. Who mints the forum admin key?** The design (§11.2, Q17) says you mint it yourself in a + NIP-07 browser extension, so that a key that can read sealed history never touches an agent + container. The workstream brief asked for it to be minted in the container and then moved. + An extension can generate the key itself. Either way, only the public key needs to come back. + Recommendation: mint it in the extension. +- **D3. Where do K_broker and K_browser start out?** `sudo` is blocked in this container, so a + key minted here would be a devuser-owned 0600 file. Every agent process runs as devuser and + could read it until you move it. The alternative is to mint each one in its holder (the + VisionClaw environment, the Podkey vault), using the same `nostr-bbs-core` path. + +## What remains, in order (no flag day) + +| Step | Who | What | +|---|---|---| +| A | agent, after D1-D3 | Mint K_broker and K_browser, back them up with `agentbox-secret-backup`, and test-restore them. You mint K_admin in your extension. | +| B | agent | Add the new pubkeys *beside* `11ed6422…`, each with a test. The agentbox verifiers are K_browser → `[interaction_plane.proxy].allowed_pubkeys` (`agentbox.toml`, boot-class, no rebuild) and K_broker → `[sovereign_mesh.relay].allowed_pubkeys` (baked by `flake.nix:1619-1621`), if VisionClaw also publishes to the embedded relay. K_broker's main verifier and the forum admin list are in the forum deploy, not in this repo. | +| C | **you: rebuild 1** | `./scripts/launch.sh rebuild dev` from tab 6. This bakes the new relay allowlist. Afterwards both old and new keys must be admitted. | +| D | you | Switch the signers one at a time: put K_broker in VisionClaw's `.env` as `ACSP_PANEL_NOSTR_PRIVKEY`, put K_browser in the Podkey vault, and put K_admin in the forum admin config. Check from the relay that the new key's events are accepted. | +| E | agent, then **you: rebuild 2** | Withdraw `11ed6422…` from forum admin, VisionClaw governance and the browser login. The relay keeps it, because it stays the container identity. Then confirm the old key is *refused* for each withdrawn job, including after a restart. | +| F | **you** | Delete the two outside copies: the house key in **VisionClaw `.env`**, and in the **browsercontainer Podkey vault**. The private half then exists only in agentbox `.env`, its runtime copy and the age backup. | + +## 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 the key +off the admin list does not change that. Moving to new epochs under K_admin protects only posts +written after the change. The only fix for the 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. From b44e78b4f0f537e4ed72fda84c590ad9435a6efb Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 09:01:25 +0000 Subject: [PATCH 09/58] fix(entrypoint): root boot PATH is store-only and Stage B is one-shot (custody W0, bypass 2) devuser could make root run its code. Both boot stages run as root, and the entrypoint PREPENDED the devuser-owned ~/workspace/.cargo/bin to PATH (Stage A for PID 1 and every supervised child, Stage B again for [program:bootstrap]). With the supervisor socket at 0766, devuser could plant a binary there named after any tool the boot calls and `supervisorctl start bootstrap`. - _ab_root_path_sanitise runs once, above the stage dispatch, so it covers both stages: only /nix/store/* and the image's fixed system dirs survive; relative, dotted, globbed and devuser-owned entries are dropped, with a fallback when nothing is left. _ab_cargo_bin_on_path is deleted (rune is baked; nothing in the boot used the workspace cargo bin). - Stage B claims a one-shot sentinel by atomic mkdir in a root 0700 state dir that Stage A arms. A replay exits 0 as a no-op; an untrusted dir (wrong owner, loose mode, symlink, missing) makes Stage B refuse and exit 1. The dir is /tmp/.agentbox-root, not /run: /run, /var/run and /var/log are tmpfs mounts owned by uid 1000, so devuser can rename anything directly under them, root-owned or not; /tmp is root 1777 and its sticky bit stops that. Move it under /run/secrets once W1 makes that a root mount point. - The runtime-env and fish snippets now APPEND the cargo bin for devuser shells only (id -u guard), so a planted or stale workspace binary cannot shadow an image tool and a root shell sourcing the file is untouched. Tests: RC-X1-01 14/14, RC-X1-02 16/16 (both red at 495f29c2d). The second commit's regex fix in RC-X1-02 corrects the test, not the contract. Co-Authored-By: jjohare --- config/entrypoint-unified.sh | 100 ++++++++++++++++++++++++++--- tests/runtime-contract/RC-X1-01.sh | 3 + tests/runtime-contract/RC-X1-02.sh | 5 +- tests/runtime-contract/RC-X1-03.sh | 3 + 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/config/entrypoint-unified.sh b/config/entrypoint-unified.sh index 393a9e8f2..b5f99a1d4 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,8 +189,72 @@ 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. Move it under /run/secrets once W1 makes that +# a root-owned mount point. Test: tests/runtime-contract/RC-X1-02.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 } @@ -262,9 +324,13 @@ 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 +# 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 \ @@ -915,7 +981,15 @@ 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). +_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) @@ -3034,10 +3108,12 @@ export VAULT_WORKING_ROOT="${VAULT_WORKING_ROOT:-}" export VAULT_WORKING_PAGES="${VAULT_WORKING_PAGES:-}" export VAULT_TRANSCRIPTS="${VAULT_TRANSCRIPTS:-}" export ONTOLOGY_PAGES_DIR="${ONTOLOGY_PAGES_DIR:-}" -# ADR-2029 D4: cargo-installed binaries (rune) before the image bakes them. -if [ -d "/home/devuser/workspace/.cargo/bin" ]; then +# ADR-2029 D4: cargo-installed binaries (cargo-audit, cargo-deny ...). Custody W0: +# devuser shells only, and APPENDED, so a planted or stale workspace binary can +# never shadow an image tool. A root shell sourcing this file is left alone. +if [ "\$(id -u)" != 0 ] && [ -d "/home/devuser/workspace/.cargo/bin" ]; then case ":\$PATH:" in *":/home/devuser/workspace/.cargo/bin:"*) : ;; - *) PATH="/home/devuser/workspace/.cargo/bin:\$PATH"; export PATH ;; + *) PATH="\$PATH:/home/devuser/workspace/.cargo/bin"; export PATH ;; esac fi EOF @@ -3133,6 +3209,10 @@ if test -f $envfile end end end +# Custody W0: the workspace cargo bin is for devuser shells only, appended. +if test (id -u) -ne 0; and test -d /home/devuser/workspace/.cargo/bin + fish_add_path --global --append --path /home/devuser/workspace/.cargo/bin +end FISHEOF chown devuser:devuser "$FISH_CONF_D/agentbox-runtime.fish" 2>/dev/null || true diff --git a/tests/runtime-contract/RC-X1-01.sh b/tests/runtime-contract/RC-X1-01.sh index ed037918c..959c47173 100755 --- a/tests/runtime-contract/RC-X1-01.sh +++ b/tests/runtime-contract/RC-X1-01.sh @@ -17,6 +17,9 @@ # 7. the shell profile snippet appends the cargo bin for devuser only, never root # # No container, no Docker, nothing executed as root. Exit 0 = all pass, 1 = any fail. +# shellcheck disable=SC2015,SC2016 +# SC2015: _ok/_bad always return 0, so `cond && _ok || _bad` is a true if/else. +# SC2016: single-quoted $ is deliberate (regexes and code run in a child bash). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENTRY="${HERE}/../../config/entrypoint-unified.sh" diff --git a/tests/runtime-contract/RC-X1-02.sh b/tests/runtime-contract/RC-X1-02.sh index c837af795..6ede43bf5 100755 --- a/tests/runtime-contract/RC-X1-02.sh +++ b/tests/runtime-contract/RC-X1-02.sh @@ -21,6 +21,9 @@ # 5. prepare moves a squatted path aside rather than trusting or following it # 6. the sentinel path is not under a devuser-owned tmpfs; /tmp is root 1777 here # 7. the Stage B prologue claims before Phase 6 and exits 0 on a replay +# shellcheck disable=SC2015,SC2016 +# SC2015: _ok/_bad always return 0, so `cond && _ok || _bad` is a true if/else. +# SC2016: single-quoted $ is deliberate (regexes and code run in a child bash). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENTRY="${HERE}/../../config/entrypoint-unified.sh" @@ -116,7 +119,7 @@ prep_line="$(grep -n '_ab_root_state_dir_prepare "\$AB_ROOT_STATE_DIR"' "$ENTRY" && _ok "Stage A prepares the state dir (line ${prep_line})" || _bad "Stage A must prepare the state dir" if [ -n "$claim_line" ]; then blk="$(sed -n "${claim_line},$((claim_line + 12))p" "$ENTRY")" - printf '%s' "$blk" | grep -qE '1\)[^;]*exit 0' && printf '%s' "$blk" | grep -qE '2\)|\*\)' \ + printf '%s' "$blk" | grep -qE '^ *1\).*; exit 0 ;;' && printf '%s' "$blk" | grep -qE '2\)|\*\)' \ && _ok "a replay exits 0 (supervisord records a clean one-shot); an untrusted dir exits non-zero" \ || _bad "replay must exit 0 and refusal must exit non-zero" "$blk" fi diff --git a/tests/runtime-contract/RC-X1-03.sh b/tests/runtime-contract/RC-X1-03.sh index 1a560c244..ad003d765 100755 --- a/tests/runtime-contract/RC-X1-03.sh +++ b/tests/runtime-contract/RC-X1-03.sh @@ -7,6 +7,9 @@ # # 1. static: the baked /etc/group heredoc in flake.nix lists no members for root # 2. live (AGENTBOX_RC_LIVE=1, in a rebuilt image): `id -G devuser` lacks 0 +# shellcheck disable=SC2015,SC2016 +# SC2015: _ok/_bad always return 0, so `cond && _ok || _bad` is a true if/else. +# SC2016: single-quoted $ is deliberate (regexes and code run in a child bash). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FLAKE="${HERE}/../../flake.nix" From 8070c1010d5b913b03164c6b0c4656cd9eb216bd Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 09:01:36 +0000 Subject: [PATCH 10/58] fix(image)!: devuser leaves group root (custody W0; REBUILD to apply, REBUILD to undo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baked /etc/group read `root:x:0:devuser`, so every devuser process ran with supplementary gid 0 and could read or write anything root left group-accessible. Custody isolation (design §2.1: "devuser ∉ {root, docker}") cannot hold while devuser is in group root. This is the one W0 change that is not flag-reversible: /etc/group is baked into the read-only image, so applying it and rolling it back both need `./scripts/launch.sh rebuild dev`. It ships alone so it can be rehearsed and reverted alone (git revert this commit, rebuild). R4: a bounded scan of /etc, /opt/agentbox, /var/lib, /run, /var/run, /var/log, /home/devuser and /tmp in the running container found no file that devuser reaches only through group 0. The three group-0 sockets (supervisor, tailscaled, nvidia-ctk hook dir) are all o+rw or o+rwx, so nothing listed loses access. An unlisted reliance would show as EACCES after the rebuild. wheel:x:998:devuser is unchanged: sudo is inert under no-new-privileges. Tests: RC-X1-03 static 2/2 (red at 495f29c2d); live `id -G devuser` check runs with AGENTBOX_RC_LIVE=1 in the rebuilt image. Co-Authored-By: jjohare --- flake.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index da6cbc1a8..e9062ef5a 100644 --- a/flake.nix +++ b/flake.nix @@ -3506,8 +3506,12 @@ ${ragflowNetworkDecl} # Strip leading whitespace introduced by Nix heredoc indentation sed -i 's/^[[:space:]]*//' $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 From 59379c86f5e9426de4c3bda31eb3ae0758f93277 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 09:01:47 +0000 Subject: [PATCH 11/58] =?UTF-8?q?docs(closeout):=20G-5=20handoff=20?= =?UTF-8?q?=E2=80=94=20D2=20decided,=20the=20operator=20creates=20K=5Fadmi?= =?UTF-8?q?n=20in=20NIP-07?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container never mints, holds or backs up K_admin; only its public key comes back for the forum admin list. Adds a key table whose K_admin row awaits that public key. D1 awaits the owner's age recipient; D3 open. Co-Authored-By: jjohare --- docs/estate-closeout/g5-key-split-handoff.md | 23 +++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/estate-closeout/g5-key-split-handoff.md b/docs/estate-closeout/g5-key-split-handoff.md index 9f048e6eb..976e632a1 100644 --- a/docs/estate-closeout/g5-key-split-handoff.md +++ b/docs/estate-closeout/g5-key-split-handoff.md @@ -39,30 +39,37 @@ other job its own key, so that the two copies outside agentbox can be deleted. `11ed6422…`'s admin role at the forum (step E below). Keep the tool: re-sealing history (Q16) would need it. -## Held: three decisions before any key is minted +## Held: decisions before any key is minted -Nothing has been minted. Minting is quick once these three are settled. +Nothing has been minted. D2 is decided; D1 is waiting on you; D3 is open. - **D1. Where are the backups encrypted to?** The repo records no operator age recipient (`age1…`). Without one, the age backup can only be keyed by something that also lives in the container, and that protects nothing. Please supply a public `age1…` recipient. Its private identity stays with you. To make the restore test meaningful, it will also use a second throw-away recipient that is destroyed after the check. -- **D2. Who mints the forum admin key?** The design (§11.2, Q17) says you mint it yourself in a - NIP-07 browser extension, so that a key that can read sealed history never touches an agent - container. The workstream brief asked for it to be minted in the container and then moved. - An extension can generate the key itself. Either way, only the public key needs to come back. - Recommendation: mint it in the extension. +- **D2. Decided (lead, 2026-10-03): you create the forum admin key (K_admin) yourself, in your + NIP-07 browser extension.** The design (§11.2, Q17) takes precedence over the earlier brief. + K_admin is never minted in, copied into or backed up from any container. Only its public key + comes back, so it can be added to the forum admin list. - **D3. Where do K_broker and K_browser start out?** `sudo` is blocked in this container, so a key minted here would be a devuser-owned 0600 file. Every agent process runs as devuser and could read it until you move it. The alternative is to mint each one in its holder (the VisionClaw environment, the Podkey vault), using the same `nostr-bbs-core` path. +## The new keys + +| Key | Job | Created by, where | Public key | +|---|---|---|---| +| K_admin | forum house admin (replaces `11ed6422…` there) | you, in your NIP-07 extension (D2) | not yet created: you send it after creating the key | +| K_broker | VisionClaw governance signer (31400/31402) | agent, after D1 and D3 | not yet minted | +| K_browser | browser sidecar login (Podkey vault) | agent, after D1 and D3 | not yet minted | + ## What remains, in order (no flag day) | Step | Who | What | |---|---|---| -| A | agent, after D1-D3 | Mint K_broker and K_browser, back them up with `agentbox-secret-backup`, and test-restore them. You mint K_admin in your extension. | +| A | you; agent after D1 and D3 | You create K_admin in your extension and send its **public** key only. The agent mints K_broker and K_browser, backs them up with `agentbox-secret-backup` to your age recipient, and test-restores them. | | B | agent | Add the new pubkeys *beside* `11ed6422…`, each with a test. The agentbox verifiers are K_browser → `[interaction_plane.proxy].allowed_pubkeys` (`agentbox.toml`, boot-class, no rebuild) and K_broker → `[sovereign_mesh.relay].allowed_pubkeys` (baked by `flake.nix:1619-1621`), if VisionClaw also publishes to the embedded relay. K_broker's main verifier and the forum admin list are in the forum deploy, not in this repo. | | C | **you: rebuild 1** | `./scripts/launch.sh rebuild dev` from tab 6. This bakes the new relay allowlist. Afterwards both old and new keys must be admitted. | | D | you | Switch the signers one at a time: put K_broker in VisionClaw's `.env` as `ACSP_PANEL_NOSTR_PRIVKEY`, put K_browser in the Podkey vault, and put K_admin in the forum admin config. Check from the relay that the new key's events are accepted. | From 6a433e6b311cde100ffaa1bbe7b66a797d6aca0b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 09:03:23 +0000 Subject: [PATCH 12/58] feat(security): flag-gated Docker socket and a GET-only read proxy (custody W0, bypass 1) devuser could drive the host's Docker daemon, which is root on the host: the entrypoint ran `chmod o+rw /var/run/docker.sock` on every boot. Any in-container custody boundary is void while that holds. - [security].role_isolation (boot-class, default false) is read once in Stage A with the existing manifest reader and exported as AGENTBOX_ROLE_ISOLATION, so supervisord and every program agree. - _ab_docker_socket_access: flag off runs the original `chmod o+rw "$sock" 2>/dev/null || true` and nothing else (byte-identical behaviour). Flag on never chmods the socket in either direction (it is the host inode, R2); if devuser can still connect (owner, group or other bits) it records degraded:docker-socket in /run/secrets/role-isolation.state and logs ROLE-ISOLATION-DEGRADED, fail loud, not fatal. The state write removes and re-creates with O_EXCL, so it never follows a planted symlink. - config/docker-read-proxy.cjs (node:http + node:fs, no new dependency): GET/HEAD only on /_ping, /version, /info, /containers/json, /containers//json and /containers//logs, with an optional /v1.N prefix. Everything else gets 403 and never reaches the daemon, including the read-shaped exfiltration routes (archive, export, attach). Encoded paths, dot segments, absolute-form and Upgrade are refused. It binds /run/docker-ro.sock with umask 0111 (no chmod race), then drops to uid/gid 65534 with only the socket's group and refuses group 0. - [program:docker-read-proxy] runs it via `env -i`, so the .env secrets PID 1 inherits never reach it. It exits 0 at once while the flag is off. TODO(custody W1): move it into the role registry. - Under the flag, Phase 8 exports DOCKER_HOST=unix:///run/docker-ro.sock for devuser shells, so `docker ps/logs/inspect` keep working and `docker exec` moves to the host shell (Q1 default). - Compose override comments state the real reach of group_add and R2. - tests/runtime-contract/README.md: RC-X1 rows and the host-half contract for R2, for the W6a rehearsal to pick up. Tests: RC-X1-04 16/16 plus a live check that is skipped until a rebuilt image runs with the flag on; RC-X1-05 6/6, including the real docker CLI against a fake daemon (ps, inspect and logs pass; exec and run fail; no mutating request reaches the daemon). Both were red at 495f29c2d. Co-Authored-By: jjohare --- config/docker-read-proxy.cjs | 192 +++++++++++++++++++++++++++++ config/entrypoint-unified.sh | 59 +++++++-- docker-compose.override.yml | 18 ++- flake.nix | 21 ++++ tests/runtime-contract/RC-X1-04.sh | 23 +++- tests/runtime-contract/README.md | 27 ++++ 6 files changed, 322 insertions(+), 18 deletions(-) create mode 100644 config/docker-read-proxy.cjs 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/entrypoint-unified.sh b/config/entrypoint-unified.sh index b5f99a1d4..28c3d8805 100644 --- a/config/entrypoint-unified.sh +++ b/config/entrypoint-unified.sh @@ -327,6 +327,11 @@ export GIT_CONFIG_GLOBAL="${GIT_CONFIG_GLOBAL:-/home/devuser/.config/git/config} # 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 +# 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)" +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" \ @@ -435,13 +440,46 @@ 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" # Claude-flow data directory (hooks write here as devuser) mkdir -p /home/devuser/.claude-flow/data 2>/dev/null || true @@ -3045,8 +3083,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' \ # ── 2. flag on, socket not widened ─────────────────────────────────────────── S="${TMP}/on.sock"; _mksock "$S" 0660; ST="${TMP}/on.state" -OUT="$(_ab_docker_socket_access "$S" 1 "$ST" 2>&1)" +# The fixture socket is ours (uid/gid of this shell), so "devuser" is played by +# an account that does not exist: it neither owns the socket nor shares its group. +OUT="$(_ab_docker_socket_access "$S" 1 "$ST" rc-x1-no-such-user 2>&1)" [ "$(_mode "$S")" = 660 ] && _ok "flag on: socket mode untouched (0660)" || _bad "flag on must not chmod" "mode $(_mode "$S")" [ "$(cat "$ST" 2>/dev/null)" = "ok:docker-socket" ] && _ok "flag on, narrow socket: state ok:docker-socket" \ || _bad "state must read ok:docker-socket" "$(cat "$ST" 2>&1)" # ── 3. flag on, host inode already widened (R2) ────────────────────────────── S="${TMP}/wide.sock"; _mksock "$S" 0666; ST="${TMP}/wide.state" -OUT="$(_ab_docker_socket_access "$S" 1 "$ST" 2>&1)" +OUT="$(_ab_docker_socket_access "$S" 1 "$ST" rc-x1-no-such-user 2>&1)" [ "$(_mode "$S")" = 666 ] && _ok "flag on, widened socket: left alone (never chmods the host inode either way)" \ || _bad "flag on must not chmod a widened socket" "mode $(_mode "$S")" [ "$(cat "$ST" 2>/dev/null)" = "degraded:docker-socket" ] && _ok "flag on, widened socket: state degraded:docker-socket" \ @@ -72,6 +79,11 @@ OUT="$(_ab_docker_socket_access "$S" 1 "$ST" 2>&1)" printf '%s' "$OUT" | grep -q 'ROLE-ISOLATION-DEGRADED docker-socket' && _ok "a grep-able ROLE-ISOLATION-DEGRADED marker is logged" \ || _bad "degraded marker must be logged" "$OUT" +S2="${TMP}/owned.sock"; _mksock "$S2" 0600; ST="${TMP}/owned.state" +_ab_docker_socket_access "$S2" 1 "$ST" "$(id -un)" >/dev/null 2>&1 +[ "$(cat "$ST" 2>/dev/null)" = "degraded:docker-socket" ] && _ok "flag on, 0600 socket owned by the user: degraded (owner bits count)" \ + || _bad "an owned socket must read degraded" "$(cat "$ST" 2>&1)" + # ── 4. symlinked state path ────────────────────────────────────────────────── VICTIM="${TMP}/victim"; printf 'precious\n' >"$VICTIM"; ln -s "$VICTIM" "${TMP}/link.state" _ab_docker_socket_access "$S" 1 "${TMP}/link.state" >/dev/null 2>&1 @@ -88,7 +100,8 @@ printf '[security]\naudit_acknowledged = true\n' >"${TMP}/off.toml" a="$(AGENTBOX_CONFIG="${TMP}/on.toml" _ab_toml_bool security role_isolation)" b="$(AGENTBOX_CONFIG="${TMP}/off.toml" _ab_toml_bool security role_isolation)" [ "$a" = 1 ] && [ "$b" = 0 ] && _ok "role_isolation reads 1 when true, 0 when absent (default false)" || _bad "flag read" "on=$a off=$b" -grep -qE '^export AGENTBOX_ROLE_ISOLATION="\$\(_ab_toml_bool security role_isolation\)"' "$ENTRY" \ +grep -qE '^AGENTBOX_ROLE_ISOLATION="\$\(_ab_toml_bool security role_isolation\)"' "$ENTRY" \ + && grep -qE '^export AGENTBOX_ROLE_ISOLATION$' "$ENTRY" \ && _ok "Stage A exports AGENTBOX_ROLE_ISOLATION from the manifest" || _bad "Stage A must export AGENTBOX_ROLE_ISOLATION" grep -qE '^_ab_docker_socket_access /var/run/docker\.sock "\$AGENTBOX_ROLE_ISOLATION"' "$ENTRY" \ && _ok "Stage A gates the socket on the flag" || _bad "Stage A must call _ab_docker_socket_access with the flag" diff --git a/tests/runtime-contract/README.md b/tests/runtime-contract/README.md index 9719e4ea2..faceac5bf 100644 --- a/tests/runtime-contract/README.md +++ b/tests/runtime-contract/README.md @@ -49,6 +49,33 @@ Jest equivalents use `test.skip` gated on `process.env.SKIP_DOCKER`. | RC-003-08 | Metrics port from manifest appears in compose ports, is bound, returns Prometheus text | | RC-003-09 | `docker inspect`: `User != 0`, `ReadonlyRootfs true`, `CapDrop` has `ALL`, ≥2 tmpfs mounts | | RC-003-10 | Desktop exception adds tmpfs entries without removing baseline `cap_drop: ALL` | +| RC-X1-01 | Root boot PATH is store-only: a probe planted in `~/workspace/.cargo/bin` never runs as root; devuser shells get the cargo bin appended | +| RC-X1-02 | Stage B is one-shot per container start (root 0700 sentinel dir in sticky `/tmp`); a `supervisorctl start bootstrap` replay is a no-op | +| RC-X1-03 | Baked `/etc/group` gives `root` no members; live (`AGENTBOX_RC_LIVE=1`): `id -G devuser` lacks 0 | +| RC-X1-04 | `chmod o+rw` on the Docker socket only with `[security].role_isolation` off; on, a still-widened socket is reported `degraded:docker-socket` | +| RC-X1-05 | `config/docker-read-proxy.cjs`: GET-only allowlist (fixtures), 403 never reaches the daemon, real `docker` CLI ps/inspect/logs pass and exec/run fail | + +The RC-X1 tests (custody X-1 step 1, workstream W0) need no container: they extract +the entrypoint functions by name and run them against a scratch filesystem. + +```bash +for f in tests/runtime-contract/RC-X1-0*.sh; do bash "$f"; done +node --test tests/runtime-contract/RC-X1-05.node-test.cjs +``` + +### Host-half contract for the role-isolation rehearsal (R2) + +The Docker socket inside the container is a bind of the **host's** +`/var/run/docker.sock` inode. Every boot before W0 ran `chmod o+rw` on it, so the +host socket is world-writable until someone narrows it **on the host**. Turning +`[security].role_isolation` on stops the container widening it but never narrows +it (that is the owner's host-side decision, Q2). Until the host runs +`chmod 0660 /var/run/docker.sock` (or the Docker daemon restarts and recreates it), +the boot writes `degraded:docker-socket` to `/run/secrets/role-isolation.state` and +logs `ROLE-ISOLATION-DEGRADED docker-socket`. The rehearsal's host half +(`scripts/activation/role-isolation-rehearsal.host.sh`, W6a) must report the host +socket's mode and owner and treat anything with other-bits set as a failed check; +the in-container half must treat `degraded:docker-socket` as red. ## Running locally From 3cd5614738f6e3d85d25961b832b1074f307c348 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 3 Oct 2026 09:05:44 +0000 Subject: [PATCH 13/58] test(custody): X-1 role-isolation rehearsal, red on today's image (W6a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acceptance gate for [security].role_isolation (custody design §4), written before any isolation code so every later workstream has a test to turn green. - scripts/activation/role-isolation-rehearsal.sh: container half, as devuser. (a) role secrets and at-rest copies refused with EACCES, /run/secrets a root 0711 mount point; (b) role programs RUNNING under their role uid, environ unreadable; (c) pods NIP-98 and a dry-run forum event made by the identity port verify with NostrBridge.verifyNip98 / nostr-tools, relay NIP-42 AUTH accepted, dm_unwrap / foreign URL / generic sign refused, one sign receipt per call; (d) producer under its role uid with a block younger than 2 x interval; (e) classified variable names absent from argv, devuser environ, supervisor environment= lines, identity.env and /var/log (names, never values); (f) not in group 0, no docker, no sudo, no role gid on the socket's group. - scripts/activation/role-isolation-rehearsal.host.sh: host half, as root on the host; refuses inside a container and on a dirty scripts/activation. Root probe piped to docker exec with a /nix/store-only PATH. Role-uid reads via setpriv, root-view environ names, host getent 960-979 collisions (§8.5), host socket mode recorded (Q2). - docs/estate-closeout/schema/x1-rehearsal.schema.json: receipt schema; evidence keys naming a value-bearing field are refused. - tests/config/role-isolation-rehearsal.test.sh: scratch root, fake /proc and supervisor; PASS, flag-off STAGED, one FAIL per check, two required-but-skipped FAILs, value-free receipts, host half. 17 cases. Exit 0 PASS, 2 STAGED (flag off), 1 FAIL; required-but-skipped is FAIL. Co-Authored-By: jjohare --- .../schema/x1-rehearsal.schema.json | 139 ++++ .../role-isolation-rehearsal.host.sh | 253 ++++++++ .../activation/role-isolation-rehearsal.sh | 594 ++++++++++++++++++ tests/config/role-isolation-rehearsal.test.sh | 335 ++++++++++ 4 files changed, 1321 insertions(+) create mode 100644 docs/estate-closeout/schema/x1-rehearsal.schema.json create mode 100755 scripts/activation/role-isolation-rehearsal.host.sh create mode 100755 scripts/activation/role-isolation-rehearsal.sh create mode 100755 tests/config/role-isolation-rehearsal.test.sh 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/scripts/activation/role-isolation-rehearsal.host.sh b/scripts/activation/role-isolation-rehearsal.host.sh new file mode 100755 index 000000000..533fa87c0 --- /dev/null +++ b/scripts/activation/role-isolation-rehearsal.host.sh @@ -0,0 +1,253 @@ +#!/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_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() { + [ -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 + +if [ -n "${HR_REGISTRY:-}" ]; then REGISTRY="$HR_REGISTRY" +elif REGISTRY="$("$DOCKER" exec -u 0 "$C" cat /etc/agentbox/role-accounts.json 2>/dev/null)" && [ -n "$REGISTRY" ]; then : +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"; 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..f18283003 --- /dev/null +++ b/scripts/activation/role-isolation-rehearsal.sh @@ -0,0 +1,594 @@ +#!/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 enabled chain's 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 when the image ships it (W1 emits it from +# lib/role-accounts.nix); otherwise derived here from the manifest with the design's §2.2 +# numbering (960 identity, 961 gateway, 962 ingress, 963 spend, then per chain from 964: +# ab-sidestr-, ab-faucet-; dreamlab first, then [sidechain.] sorted). +# +# Identity-port client contract assumed for (c) (W3a implements it; design §2.5): +# nostr-pod-bridge sign-request JSON params on stdin, JSON on stdout, exit 0 +# pubkey {key} -> {"pubkey": ""} +# nip98 {key, method, url, payload_sha256} -> {"header": "Nostr "} +# forum_event {key, kind, content, dry_run} -> {"event": {...signed...}} +# nip42_auth {key, relay, challenge} -> {"event": {...kind 22242...}} +# a refusal exits non-zero with {"refused": ""}. +# +# --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. + +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 ──────────────────────────────────────────────────────────────────────────── +IDENTITY_ENV='["AGENTBOX_PRIVKEY_HEX","AGENTBOX_NSEC","AGENTBOX_BRIDGE_SK","AGENTBOX_AGENT_PRIVKEY_HEX","JUNKIEJARVIS_PRIVKEY_HEX","CONCIERGE_PRIVKEY_HEX"]' +INGRESS_ENV='["NIP98_PROXY_ALLOW_BEARER","NIP98_PROXY_SESSION_SECRET"]' + +derive_registry() { + local chains=() c i=0 base=964 enabled faucet port interval cred fkey prod_prog fauc_prog roles + grep -q '^\[sidechain\]' "$MANIFEST" && chains+=(dreamlab) + while IFS= read -r c; do [ -n "$c" ] && chains+=("$c"); done < <(sed -n 's/^\[sidechain\.\([A-Za-z0-9_-]*\)\][[:space:]]*$/\1/p' "$MANIFEST" | sort) + roles="$(jq -nc --argjson ienv "$IDENTITY_ENV" --argjson genv "$INGRESS_ENV" '[ + {name:"ab-identity",uid:960,gid:960,programs:["nostr-relay"],files:["nostr.key"],env:$ienv, + 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"]}, + {name:"ab-gateway",uid:961,gid:961,programs:["nostr-gateway"],files:[],env:[],at_rest:[],at_rest_dirs:[],legacy:[]}, + {name:"ab-ingress",uid:962,gid:962,programs:["nip98-proxy"],files:[],env:$genv,at_rest:[],at_rest_dirs:[],legacy:[]}, + {name:"ab-spend",uid:963,gid:963,programs:[],files:[],env:[],at_rest:[],at_rest_dirs:[],legacy:[],deferred:"Q6: pay402 and Hitch stay devuser-class in step 1"} + ]')" + for c in "${chains[@]}"; do + if [ "$c" = dreamlab ]; then + enabled="$(toml_bool sidechain enabled)"; faucet="$(toml_bool sidechain faucet)" + port="$(toml_int sidechain port 3450)"; interval="$(toml_int sidechain interval 600)" + cred="$(toml_val sidechain parent_credential_file)"; cred="${cred:-/var/lib/agentbox/secrets/sidestr-tbtc4.cookie}" + fkey="$(toml_val sidechain faucet_key_file)"; prod_prog=sidestr-producer; fauc_prog=sidestr-faucet + else + enabled=false; [ "$(toml_bool sidechain enabled)" = true ] && enabled="$(toml_bool "sidechain.$c" enabled)" + faucet="$(toml_bool "sidechain.$c" faucet)" + port="$(toml_int "sidechain.$c" port 3450)"; interval="$(toml_int "sidechain.$c" interval 600)" + cred="$(toml_val "sidechain.$c" parent_credential_file)" + fkey="$(toml_val "sidechain.$c" faucet_key_file)"; prod_prog="sidestr-producer-$c"; fauc_prog="sidestr-faucet-$c" + fi + [ "$enabled" = true ] || faucet=false + roles="$(jq -c --arg c "$c" --argjson u $((base + 2 * i)) --argjson en "$enabled" --argjson fa "$faucet" \ + --argjson port "$port" --argjson iv "$interval" --arg cred "$cred" --arg fkey "$fkey" \ + --arg pp "$prod_prog" --arg fp "$fauc_prog" '. + [ + {name:("ab-sidestr-" + $c),uid:$u,gid:$u,chain:$c,port:$port,interval:$iv,enabled:$en, + programs:(if $en then [$pp] else [] end),files:(if $en then ["signer.key","parent.cred"] else [] end),env:[], + at_rest:(if $en then ["/var/lib/agentbox/secrets/sidestr-" + $c + ".key"] + (if $cred == "" then [] else [$cred] end) else [] end), + at_rest_dirs:(if $en then ["/var/lib/agentbox/secrets"] else [] end),legacy:[]}, + {name:("ab-faucet-" + $c),uid:($u + 1),gid:($u + 1),chain:$c,enabled:$fa, + programs:(if $fa then [$fp] else [] end),files:(if $fa then ["treasury.key"] else [] end),env:[], + at_rest:(if $fa then ["/var/lib/agentbox/secrets/sidestr-faucet-" + $c + ".key"] else [] end),at_rest_dirs:[], + legacy:(if $fa and $fkey != "" then [$fkey] else [] end)} + ]' <<<"$roles")" + i=$((i + 1)) + done + jq -c --arg src "derived from $MANIFEST (design §2.2 numbering)" '{source:$src, roles:., classified_root_env:[{name:"TAILSCALE_AUTHKEY",holder:"tailscale-up (root)"}]}' <<<"$roles" +} + +if [ -n "${RH_REGISTRY:-}" ]; then REGISTRY="$(jq -c . "$RH_REGISTRY")" || die "RH_REGISTRY is not JSON" +elif [ -r "$R/etc/agentbox/role-accounts.json" ]; then REGISTRY="$(jq -c --arg src "$R/etc/agentbox/role-accounts.json" '.source = $src' "$R/etc/agentbox/role-accounts.json")" || die "role-accounts.json is not JSON" +else REGISTRY="$(derive_registry)" || die "could not derive the role table"; 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 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}')" + 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="/run/secrets/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 // "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 tip h t age pid ru eu n=0 deadline h2 obs + while IFS=$'\t' read -r role ruid chain port iv prog; do + n=$((n + 1)) + 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) | [.name, .uid, .chain, .port, .interval, .programs[0]] | @tsv' <<<"$REGISTRY") + [ "$n" -gt 0 ] || row d producers "at least one enabled chain" "no enabled producer in the role table" 0 '{}' +} + +# ── (e) no secret is ambient (names, never values) ──────────────────────────────────────── +# hits_evidence