From ea9f4c239991da2dd6f409ecfc94910e72968dab Mon Sep 17 00:00:00 2001 From: bj Date: Sun, 31 May 2026 23:10:09 -0700 Subject: [PATCH] =?UTF-8?q?v0.9.0:=20broker=20mode=20is=20the=20DEFAULT=20?= =?UTF-8?q?=E2=80=94=20wire=20the=20remote=20kill-switch=20into=20every=20?= =?UTF-8?q?Cloverleaf-Larry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Larry remote kill-switch (Pax design; Mack's broker on .135 LAN 8181 / Tailscale 100.86.16.114:8181). Deployed Larry no longer holds a long-lived sk-ant-… key: it holds a per-deployment enrollment secret, mints a short-lived token from the broker, and routes every LLM call THROUGH the broker /v1/messages (real key injected server-side). set-authorized false => the deployment 401s and dies, no box access required. - LARRY_AUTH_MODE=broker is the DEFAULT (was apikey). Self-update flips existing installs to broker-mode too, so upgrading Gundersen delivers the kill-switch. Escape hatch (documented, not default): LARRY_AUTH_MODE=apikey (no kill-switch, never for PHI boxes). - New lib/broker.sh: enroll+mint, fail-closed heartbeat, best-effort PHI wipe (reuses uninstall-larry.sh's shred/overwrite secure-delete + LARRY_HOME guard). - Fail-closed preflight at launch + in-REPL heartbeat (default 60s, 3-miss budget): disabled => refuse to run (+ PHI wipe for profile:phi); unreachable past budget => refuse to run (NO wipe on a network blip — only an explicit disable wipes). - call_api / call_api_stream broker branch: Bearer short-lived token, no x-api-key, token never on disk. - install-larry.sh enrollment provisioning: LARRY_DEPLOYMENT_ID + LARRY_ENROLL_SECRET (+ LARRY_PROFILE/LARRY_BROKER_URL) baked 0600 + into the shim; box shows up in the dashboard ready to toggle. - /auth reports broker state. Reachability (flagged for Bryan): the broker is LAN + Tailscale only (no public route). Egress-restricted boxes reach it over Tailscale (default URL = tailnet). A box that can reach neither fail-closes = won't run (correct kill, useless work state) — such a box MUST run Tailscale, or Bryan must stand up a hardened public broker ingress. Bug fixed in test: _broker_json_field jq `// empty` rendered literal false as empty, mis-classifying a DISABLED deployment as an unreachable MISS (delaying fail-close + skipping the PHI wipe). Fixed to `if has($k) then .[$k] else "" end`. Verified end-to-end against the live broker: enroll -> mint -> proxied call -> disable -> instant 401 + heartbeat fail-close + 5 PHI files shredded. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 57 ++++++++ MANIFEST | 13 +- VERSION | 2 +- install-larry.sh | 84 +++++++++++- larry.sh | 232 +++++++++++++++++++++++++++++--- lib/broker.sh | 334 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 695 insertions(+), 27 deletions(-) create mode 100644 lib/broker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 472d5cb..f058d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,63 @@ All notable changes to `cloverleaf-larry` / `larry-anywhere` are recorded here. Versioning is loose-semver; bumps trigger the in-process self-update on every running client via `LARRY_BASE_URL` + `MANIFEST`. +## v0.9.0 — 2026-05-31 + +**Broker mode is the DEFAULT — the remote kill-switch is wired into every +Cloverleaf-Larry deployment, including self-update of existing installs.** + +Phase 3 of the Larry remote kill-switch (Pax design brief +`Deliverables/2026-05-31-larry-remote-killswitch-design.md`; server broker by +Mack at `192.168.20.135:8181` / Tailscale `100.86.16.114:8181`). A deployed +Larry no longer holds a long-lived `sk-ant-…` key: it holds a per-deployment +enrollment secret, mints a short-lived token from the broker, and routes every +LLM call THROUGH the broker (`/v1/messages`), which injects the real key +server-side. Flip `set-authorized false` in the broker and the deployment +401s and dies — no access to the box required. + +### New / changed behavior +- **`LARRY_AUTH_MODE=broker` is the DEFAULT** (was `apikey`). Unset => broker. + Self-update flips existing installs to broker-mode too: upgrading Gundersen + delivers the kill-switch automatically, per Bryan's requirement. Escape hatch + (documented, not default): `LARRY_AUTH_MODE=apikey` keeps the legacy baked-key + rail (NO kill-switch) — never for PHI boxes. +- **New `lib/broker.sh`** — enroll+mint (`/enroll-mint`), fail-closed heartbeat + (`/authorized`), best-effort PHI wipe (reuses `uninstall-larry.sh`'s + shred/overwrite secure-delete + the same hard LARRY_HOME safety guard). +- **Fail-closed preflight** at launch (after self-update, before any model call): + `authorized:false` => for `profile:phi`, best-effort local PHI wipe, then refuse + to run; unreachable past `LARRY_HEARTBEAT_MAX_MISS` (default 3) => refuse to run + (NO wipe on a mere network blip — only an explicit disable wipes). +- **In-REPL heartbeat** every `LARRY_HEARTBEAT_INTERVAL` (default 60s): a + mid-session disable stops the next turn (and wipes PHI on a phi profile). The + per-call token re-mint in `call_api`/`call_api_stream` is the hard stop on top. +- **`call_api` / `call_api_stream` broker branch:** `Authorization: Bearer + `, NO `x-api-key`. Token never touches disk. +- **Enrollment provisioning** in `install-larry.sh`: pass `LARRY_DEPLOYMENT_ID` + + `LARRY_ENROLL_SECRET` (+ `LARRY_PROFILE`, `LARRY_BROKER_URL`) at install → + stored 0600, baked into the `larry` shim so every launch is broker-mode and the + box shows up in the dashboard ready to toggle. +- **`/auth`** now reports broker state (deployment id, profile, heartbeat + cadence, token held) and confirms "no sk-ant-… key is stored — kill-switch ON." + +### Reachability (the design tension, flagged for Bryan) +Broker mode means the client MUST reach the broker to function. The broker is +**LAN + Tailscale only — no public route.** On an egress-restricted box (the +Gundersen Cloudflare block that 28'd `git.bjnoela.com`), the client reaches the +broker over **Tailscale** (`LARRY_BROKER_URL=http://100.86.16.114:8181`, the +default). If a box can reach NEITHER LAN nor Tailscale, broker-mode fail-closes += the agent will not run — a correct KILL state but a useless WORKING state. So +a real deployment on a locked-down network MUST run Tailscale (or Bryan must +stand up a hardened public broker ingress with its own auth). The installer and +README say this explicitly so no one ships a silently-bricked box. + +### Bug fixed during integration test +- `_broker_json_field` used jq `// empty`, which renders a literal `false` as + empty — `authorized:false` would have mis-classified a DISABLED deployment as + an unreachable MISS (delaying fail-close past the miss budget and SKIPPING the + PHI wipe). Fixed to `if has($k) then .[$k] else "" end`. Verified: disable now + fail-closes immediately and the phi wipe fires. + ## v0.8.34 — 2026-05-31 **Hardened `uninstall-larry.sh` into a first-class, healthcare-grade decommission diff --git a/MANIFEST b/MANIFEST index dcb26b3..85f5346 100644 --- a/MANIFEST +++ b/MANIFEST @@ -23,17 +23,17 @@ # scripts/make-manifest.sh and bump VERSION. # Top-level scripts -larry.sh e58a26763dc8035da95dee0d00f9270b8cde683341377bfe896388248a269a1d +larry.sh 55c6fb42f09c923e6a8f5a8dfa9dbdd8fb5a6012dd5ac6707f87e853abcec234 larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831 larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0 -install-larry.sh 0779dd74c0cecef174edbe7782da275be9bf6d2ec7e6ae88c1de46c666adc8b2 +install-larry.sh 072a036ad5bbf80e866cfd2dd74de50f8defd69a3f835032579b0cb9d421ad5b uninstall-larry.sh c53ad2d8354c7adeb243b541f027f3f481e4a8661eecfd7af14d7ca53cfcaad9 # Metadata -VERSION 437e52707aa48e9ee29b4469dc0790ad646390c08644c415a5171052487fd280 +VERSION 9d8c94f1ad3ea96b1e2ac4914fda4cb93c76b4a3e0d8cc6dd8976d6c0b227d15 MANUAL.md 5ff54d6d5fae826f8b3da1eb3be6476076bb15f9b1417a4de285e59ea37e1b1f -CHANGELOG.md 25b82ebf360bb345bb1d7008386532773e9bf55d67b331a7d572d63ff4ab87ae +CHANGELOG.md b3c48567452b4302b1167a3a9fb11f02964312a2f03ec393f8d35c9a1d392d90 # Agent personas (system-prompt overlays) agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1 @@ -52,6 +52,11 @@ lib/fetch-safe.sh abecf0045b9856f63ffa346119443c11de56547344be32bddaed9fbae6b021 # Auth implementation lib/oauth.sh 04a93376f88fe53cc1c86a5dbe577735c60375dadd4f2fda55b921ef3cddf22b +# v0.9.0: broker client — the remote kill-switch (default rail). Enroll+mint a +# short-lived token, route LLM calls through the broker, fail-closed heartbeat, +# best-effort PHI wipe on disable. +lib/broker.sh fe05f7b349d68239b3683b1f8c3139358f586cb583c907638483f1d3389959c4 + # Secure SSH with ControlMaster (password hidden from Larry-the-LLM) lib/ssh-helper.sh 18df1f1f1936c930ba0197c0e0b4bd89c027500de99b56067b620ca9144f6e9e diff --git a/VERSION b/VERSION index b326a53..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.34 +0.9.0 diff --git a/install-larry.sh b/install-larry.sh index 8bde20d..9fa3231 100755 --- a/install-larry.sh +++ b/install-larry.sh @@ -225,6 +225,7 @@ fetch uninstall-larry.sh "$LARRY_HOME/uninstall-larry.sh" fetch lib/fetch-safe.sh "$LARRY_HOME/lib/fetch-safe.sh" fetch lib/cygwin-safe.sh "$LARRY_HOME/lib/cygwin-safe.sh" fetch lib/oauth.sh "$LARRY_HOME/lib/oauth.sh" +fetch lib/broker.sh "$LARRY_HOME/lib/broker.sh" fetch lib/ssh-helper.sh "$LARRY_HOME/lib/ssh-helper.sh" fetch lib/lessons.sh "$LARRY_HOME/lib/lessons.sh" fetch lib/hl7-sanitize.sh "$LARRY_HOME/lib/hl7-sanitize.sh" @@ -393,6 +394,68 @@ else esac fi +# ───────────────────────────────────────────────────────────────────────────── +# v0.9.0 — BROKER ENROLLMENT (the remote kill-switch; DEFAULT for every deploy). +# +# Broker mode needs two facts baked per-box: a deployment id (its row in the +# broker dashboard) and the enrollment secret (exchanged for short-lived tokens). +# Bryan provisions them ONCE per box via env at install time: +# +# docker exec larry-broker python /app/broker_ctl.py enroll gundersen-epic \ +# --client "Gundersen" --profile phi --note "Epic interface box" +# # copy the printed secret, then on the target box: +# LARRY_DEPLOYMENT_ID=gundersen-epic \ +# LARRY_ENROLL_SECRET= \ +# LARRY_PROFILE=phi \ +# LARRY_BROKER_URL=http://100.86.16.114:8181 \ +# bash install-larry.sh +# +# The id+profile show up in Iris's dashboard ready to toggle. The secret is +# stored 0600 at $LARRY_HOME/.enroll-secret and NEVER printed/committed. If the +# operator skips enrollment, the box still installs but broker-mode will fail- +# closed on first launch (no secret) — they must enroll, or deliberately opt out +# with LARRY_AUTH_MODE=apikey (escape hatch, NO kill-switch — not for PHI boxes). +# ───────────────────────────────────────────────────────────────────────────── +BROKER_ENROLLED=0 +if [ -n "${LARRY_DEPLOYMENT_ID:-}" ] || [ -n "${LARRY_ENROLL_SECRET:-}" ]; then + if [ -z "${LARRY_DEPLOYMENT_ID:-}" ]; then + warn "LARRY_ENROLL_SECRET given without LARRY_DEPLOYMENT_ID — skipping enrollment." + elif [ -z "${LARRY_ENROLL_SECRET:-}" ]; then + warn "LARRY_DEPLOYMENT_ID given without LARRY_ENROLL_SECRET — skipping enrollment." + else + umask 077 + printf '%s\n' "${LARRY_DEPLOYMENT_ID//$'\r'/}" > "$LARRY_HOME/.deployment-id" + printf '%s\n' "${LARRY_ENROLL_SECRET//$'\r'/}" > "$LARRY_HOME/.enroll-secret" + chmod 600 "$LARRY_HOME/.deployment-id" "$LARRY_HOME/.enroll-secret" 2>/dev/null || true + BROKER_ENROLLED=1 + ok "broker-enrolled as deployment '${LARRY_DEPLOYMENT_ID}' (profile=${LARRY_PROFILE:-default})" + ok " enrollment secret stored 0600 at $LARRY_HOME/.enroll-secret (never printed)" + fi +fi + +# Bake the broker config into the `larry` shim so EVERY launch is broker-mode +# (the kill-switch is wired in by default, not something the operator has to +# remember to export). The shim already sets LARRY_HOME; we prepend the broker +# env. LARRY_AUTH_MODE is left to default to broker in larry.sh — but we pin the +# id/profile/broker-url here so the box phones the right broker under the right +# name. (apikey opt-out: re-run with LARRY_AUTH_MODE=apikey or edit this shim.) +if [ -f "$LARRY_BIN_DIR/larry" ]; then + _shim_inject="" + [ -n "${LARRY_BROKER_URL:-}" ] && _shim_inject="${_shim_inject}export LARRY_BROKER_URL=\"${LARRY_BROKER_URL}\"\n" + [ -n "${LARRY_DEPLOYMENT_ID:-}" ] && _shim_inject="${_shim_inject}export LARRY_DEPLOYMENT_ID=\"${LARRY_DEPLOYMENT_ID}\"\n" + [ -n "${LARRY_PROFILE:-}" ] && _shim_inject="${_shim_inject}export LARRY_PROFILE=\"${LARRY_PROFILE}\"\n" + if [ -n "$_shim_inject" ]; then + # Insert the exports just before the final `exec` line of the shim. + awk -v inj="$_shim_inject" ' + /^exec / && !done { printf "%s", inj; done=1 } { print } + ' "$LARRY_BIN_DIR/larry" > "$LARRY_BIN_DIR/larry.tmp" 2>/dev/null \ + && mv "$LARRY_BIN_DIR/larry.tmp" "$LARRY_BIN_DIR/larry" \ + && chmod +x "$LARRY_BIN_DIR/larry" \ + && ok "broker config baked into shim $LARRY_BIN_DIR/larry" + rm -f "$LARRY_BIN_DIR/larry.tmp" 2>/dev/null || true + fi +fi + # ───────────────────────────────────────────────────────────────────────────── # Done # ───────────────────────────────────────────────────────────────────────────── @@ -401,9 +464,24 @@ say "install complete (no system changes were made; everything lives under $LARR say "origin (single-source, v0.7.4): $LARRY_BASE_URL" echo "" echo "Next steps:" -echo " 1) export ANTHROPIC_API_KEY=sk-ant-... (or larry will prompt on first run)" -echo " 2) larry (or $LARRY_HOME/larry.sh)" -echo " 3) larry /path/to/cloverleaf/site_root (to start with a working dir)" +if [ "$BROKER_ENROLLED" = "1" ]; then + echo " AUTH: broker mode (DEFAULT) — NO API key on this box. The kill-switch is ON." + echo " deployment '${LARRY_DEPLOYMENT_ID}' will appear in the dashboard, ready to toggle." + echo " REACHABILITY: the broker is LAN + Tailscale only (no public route). This box MUST" + echo " reach ${LARRY_BROKER_URL:-the broker} — over Tailscale on an egress-restricted network." + echo " If it cannot reach the broker, larry FAIL-CLOSES (refuses to run). That is a correct" + echo " kill state but a useless working state — bring Tailscale up first." + echo " 1) larry (or $LARRY_HOME/larry.sh)" + echo " 2) larry /path/to/cloverleaf/site_root (to start with a working dir)" +else + echo " AUTH: broker mode is the DEFAULT but this box was NOT enrolled (no LARRY_DEPLOYMENT_ID/" + echo " LARRY_ENROLL_SECRET given). larry will FAIL-CLOSED on launch until you either:" + echo " (a) enroll: docker exec larry-broker python /app/broker_ctl.py enroll ... ," + echo " then re-run install with LARRY_DEPLOYMENT_ID/LARRY_ENROLL_SECRET/LARRY_PROFILE; or" + echo " (b) opt out of the kill-switch (NOT for PHI boxes): export LARRY_AUTH_MODE=apikey" + echo " and run /set-api-key." + echo " 1) larry (or $LARRY_HOME/larry.sh)" +fi echo "" echo "Reverse SSH tunnel (optional, run in another shell or backgrounded):" echo " $LARRY_HOME/larry-tunnel.sh --serveo # zero-config" diff --git a/larry.sh b/larry.sh index 0fbb04c..42651b9 100755 --- a/larry.sh +++ b/larry.sh @@ -48,13 +48,27 @@ # Gitea HTML sign-in page (HTTP 200), the updater now # FAILS LOUD instead of parsing HTML as file content. # ANTHROPIC_API_KEY overrides $LARRY_HOME/.api-key and $LARRY_HOME/.env if set -# LARRY_AUTH_MODE auth rail. DEFAULT = apikey (sanctioned x-api-key rail). -# Set to "oauth" to opt INTO subscription OAuth — OFF by -# default because it requires impersonating the official -# Claude Code client, which Anthropic blocks and which -# flags your Max account. No silent OAuth fallback. +# LARRY_AUTH_MODE auth rail. DEFAULT = broker (v0.9.0): NO baked key; the +# client mints a short-lived token from the broker and +# routes LLM calls through it (/v1/messages), gated by a +# fail-closed /authorized heartbeat — the remote kill- +# switch. Escape hatch: "apikey" reverts to the legacy +# per-client x-api-key rail (baked key, no kill-switch). +# "oauth" opts INTO subscription OAuth — discouraged (it +# impersonates Claude Code, flags your Max account). +# LARRY_BROKER_URL broker base URL (default tailnet http://100.86.16.114:8181; +# LAN http://192.168.20.135:8181). Broker is LAN+Tailscale +# only — an egress-restricted box reaches it over Tailscale. +# LARRY_DEPLOYMENT_ID this deployment's id in the broker registry. +# LARRY_ENROLL_SECRET the per-deployment enrollment secret (or stored 0600 at +# $LARRY_HOME/.enroll-secret). Baked at install, never logged. +# LARRY_PROFILE "default" | "phi". phi => best-effort local PHI wipe when +# the broker disables the deployment. +# LARRY_HEARTBEAT_INTERVAL seconds between in-REPL /authorized polls (default 60). +# LARRY_HEARTBEAT_MAX_MISS consecutive unreachable misses before fail-close (3). # LARRY_API_KEY_FILE per-client key store (default $LARRY_HOME/.api-key, 0600). -# Set via /set-api-key or larry-auth.sh --api-key. +# Set via /set-api-key or larry-auth.sh --api-key. APIKEY +# RAIL ONLY (escape hatch). # # Slash commands during chat: # /quit /exit /q exit @@ -85,7 +99,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.8.33" +LARRY_VERSION="0.9.0" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" # ───────────────────────────────────────────────────────────────────────────── @@ -207,7 +221,7 @@ LARRY_LAST_RESP_HEADERS="" # OPT-IN OAUTH (discouraged): set LARRY_AUTH_MODE=oauth explicitly. larry prints # a one-time account-risk warning and fires the OAuth rail. There is NO silent # OAuth fallback — larry never auto-pokes the impersonation tripwire. -LARRY_AUTH_MODE="${LARRY_AUTH_MODE:-}" # "", "apikey", or "oauth"; resolved below +LARRY_AUTH_MODE="${LARRY_AUTH_MODE:-}" # "", "broker", "apikey", or "oauth"; resolved below # Per-client API-key file (the secure provisioning store; see /set-api-key). # Mode 0600, owner-only, CR-stripped on read. Each client machine holds its OWN @@ -215,6 +229,40 @@ LARRY_AUTH_MODE="${LARRY_AUTH_MODE:-}" # "", "apikey", or "oauth"; resolved be # console.anthropic.com. The key never leaves the machine it is entered on. LARRY_API_KEY_FILE="${LARRY_API_KEY_FILE:-$LARRY_HOME/.api-key}" +# ───────────────────────────────────────────────────────────────────────────── +# v0.9.0: BROKER MODE IS THE DEFAULT (Bryan, 2026-05-31 — the remote kill-switch) +# ───────────────────────────────────────────────────────────────────────────── +# Every Cloverleaf-Larry deployment is wired to the kill-switch by default. In +# broker mode the client holds NO long-lived sk-ant-… key: it holds a +# per-deployment ENROLLMENT SECRET, exchanges it for a SHORT-LIVED token from the +# broker Bryan controls (on .135), and routes every LLM call THROUGH the broker +# (/v1/messages) — the broker injects the real Anthropic key server-side. Flip +# `set-authorized false` in the broker and the deployment 401s and dies, +# with NO access to the box required. A fail-closed heartbeat (GET /authorized) +# refuses to run when disabled (or after N unreachable misses) and, for a +# profile:phi deployment, runs a best-effort local PHI wipe. See lib/broker.sh +# and Deliverables/2026-05-31-larry-remote-killswitch-design.md. +# +# ESCAPE HATCH (documented, NOT the default): LARRY_AUTH_MODE=apikey reverts to +# the legacy per-client x-api-key rail (a baked key on the box). Use it only for +# a deployment Bryan deliberately wants OFF the kill-switch (e.g. a personal box +# with no PHI and no broker reachability). PHI deployments must stay on broker. +# +# Broker config (resolved/loaded by lib/broker.sh): +# LARRY_BROKER_URL broker base (default tailnet http://100.86.16.114:8181 +# so an off-LAN client works; LAN is :8181 on .20.135). +# LARRY_DEPLOYMENT_ID this box's id in the broker registry. +# LARRY_ENROLL_SECRET the enrollment secret (or $LARRY_HOME/.enroll-secret). +# LARRY_PROFILE "default" | "phi" (phi => wipe local PHI on disable). +# LARRY_HEARTBEAT_INTERVAL / LARRY_HEARTBEAT_MAX_MISS (default 60s / 3 misses). +LARRY_BROKER_URL="${LARRY_BROKER_URL:-http://100.86.16.114:8181}" +LARRY_DEPLOYMENT_ID="${LARRY_DEPLOYMENT_ID:-}" +LARRY_PROFILE="${LARRY_PROFILE:-default}" +LARRY_HEARTBEAT_INTERVAL="${LARRY_HEARTBEAT_INTERVAL:-60}" +LARRY_HEARTBEAT_MAX_MISS="${LARRY_HEARTBEAT_MAX_MISS:-3}" +# Wall-clock of the last successful in-REPL heartbeat (epoch). 0 = none yet. +_LARRY_LAST_HEARTBEAT=0 + # ───────────────────────────────────────────────────────────────────────────── # Colors (only if stdout is a tty) # ───────────────────────────────────────────────────────────────────────────── @@ -592,20 +640,31 @@ _load_api_key_into_env() { return 0 } -if [ "$LARRY_AUTH_MODE" = "oauth" ]; then +if [ "$LARRY_AUTH_MODE" = "broker" ]; then + # DEFAULT (v0.9.0): broker mode. No key resolution here — the real key lives + # ONLY on the broker. Actual enrollment + the fail-closed preflight happen at + # the deferred broker-resolution block (after self_update + lib sourcing), so + # a freshly-synced lib/broker.sh is in place and err()/log() are available. + # LARRY_API_URL is repointed at the broker there. + : +elif [ "$LARRY_AUTH_MODE" = "oauth" ]; then # Explicit opt-in to the OAuth-impersonation rail. We honor it but DO load the # API key too (so /logout or a manual flip lands on a working rail) and warn # once (see _warn_oauth_optin_once, fired at first use). No spoofing happens # here — call_api's OAuth branch is the only place the OAuth token is sent. _load_api_key_into_env -else - # DEFAULT: API key. Resolve a key from the per-client file or legacy .env. +elif [ "$LARRY_AUTH_MODE" = "apikey" ]; then + # ESCAPE HATCH (explicitly chosen): legacy per-client x-api-key rail. Resolve a + # key from the per-client file or legacy .env. _load_api_key_into_env - if [ -n "${ANTHROPIC_API_KEY:-}" ]; then - LARRY_AUTH_MODE="apikey" - else - LARRY_AUTH_MODE="" # no key yet → first-run prompt guides to /set-api-key - fi + [ -n "${ANTHROPIC_API_KEY:-}" ] || LARRY_AUTH_MODE="" # chose apikey but none set → first-run prompt +else + # UNSET => broker is the DEFAULT for every Cloverleaf-Larry (the kill-switch is + # on unless the operator deliberately opts out). Resolve to broker; if a + # deployment id/secret can be found we stay broker, otherwise the deferred + # block guides setup (and, only as the documented escape hatch, an operator can + # relaunch with LARRY_AUTH_MODE=apikey). + LARRY_AUTH_MODE="broker" fi # Snapshot the operator's CHOSEN primary auth mode for diagnostics/status. LARRY_PRIMARY_AUTH_MODE="$LARRY_AUTH_MODE" @@ -1233,6 +1292,11 @@ self_update # nagging Bryan for a credential he won't use. if [ "$LARRY_NO_API" = "1" ]; then LARRY_AUTH_MODE="none" +elif [ "$LARRY_AUTH_MODE" = "broker" ]; then + # Broker mode needs NO API key prompt — the real key lives on the broker. The + # enroll/mint + fail-closed preflight run at the broker-resolution block below + # (after lib/ is sourced). Do nothing here. + : elif [ -z "$LARRY_AUTH_MODE" ]; then prompt_first_run_auth fi @@ -1652,6 +1716,74 @@ else rtrim() { local v="${1:-}"; printf '%s' "${v%"${v##*[![:space:]]}"}"; } fi +# ───────────────────────────────────────────────────────────────────────────── +# v0.9.0: BROKER RESOLUTION — enroll + mint + fail-closed preflight (the remote +# kill-switch). Runs HERE (after self_update synced a fresh lib/broker.sh, after +# the lib dir is resolved, and after err()/log() exist) for every deployment in +# broker mode. NO model call has fired yet, so a disabled deployment never bills. +# ───────────────────────────────────────────────────────────────────────────── +if [ "$LARRY_AUTH_MODE" = "broker" ] && [ "$LARRY_NO_API" != "1" ]; then + if [ -n "$LARRY_LIB_DIR" ] && [ -r "$LARRY_LIB_DIR/broker.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LARRY_LIB_DIR/broker.sh" + else + err "broker mode is the default but lib/broker.sh is missing — cannot confirm authorization." + err " Fail-closed: refusing to run. Reinstall larry-anywhere, or (escape hatch, no kill-switch)" + err " relaunch with LARRY_AUTH_MODE=apikey after running /set-api-key." + exit 1 + fi + + # Resolve a deployment id: explicit env wins; else derive a stable per-box id so + # "install larry on a box" auto-enrolls under a named deployment in the dashboard. + if [ -z "$LARRY_DEPLOYMENT_ID" ]; then + if [ -f "$LARRY_HOME/.deployment-id" ]; then + LARRY_DEPLOYMENT_ID="$(strip_cr "$(cat "$LARRY_HOME/.deployment-id" 2>/dev/null)")" + LARRY_DEPLOYMENT_ID="${LARRY_DEPLOYMENT_ID//$'\n'/}" + fi + fi + + # FAIL-CLOSED PREFLIGHT: confirm authorization BEFORE minting a token or firing + # any model call. authorized:false => (phi: best-effort wipe) + refuse to run. + # Unreachable past the miss budget => refuse to run (no wipe — see broker.sh). + _broker_preflight_gate; _bpf_rc=$? + case "$_bpf_rc" in + 0) + # Authorized. Mint the first short-lived token and route through the broker. + _broker_ensure_token >/dev/null; _bmt_rc=$? + if [ "$_bmt_rc" != "0" ]; then + if [ "$_bmt_rc" = "3" ]; then + err "broker refused to mint a token (deployment unknown/bad-secret/revoked) — refusing to run." + [ "${_BROKER_LAST_PROFILE:-$LARRY_PROFILE}" = "phi" ] && _broker_on_disabled + else + err "broker unreachable while minting a token — fail-closed, refusing to run." + err " Reachability: the broker is LAN + Tailscale only. On an egress-restricted box," + err " ensure Tailscale is up and LARRY_BROKER_URL points at the tailnet ($LARRY_BROKER_URL)." + fi + exit 1 + fi + # Point every LLM call at the broker proxy. call_api/call_api_stream send + # the Bearer token in broker mode (see those functions). The real sk-ant-… + # is injected server-side; it never touches this box. + LARRY_API_URL="$LARRY_BROKER_URL/v1/messages" + _LARRY_LAST_HEARTBEAT="$(date +%s 2>/dev/null || echo 0)" + log "broker mode: deployment '$LARRY_DEPLOYMENT_ID' authorized (profile=${_BROKER_LAST_PROFILE:-$LARRY_PROFILE}); LLM calls routed through $LARRY_BROKER_URL" + ;; + 3) + # Explicit disable. _broker_preflight_gate already ran the PHI wipe (phi). + err "refusing to run: deployment '$LARRY_DEPLOYMENT_ID' is DISABLED in the broker." + exit 1 + ;; + *) + # Unreachable past the budget. This is the documented reachability failure. + err "refusing to run: cannot reach the broker at $LARRY_BROKER_URL to confirm authorization (fail-closed)." + err " The broker is LAN + Tailscale only (no public route). If this box is on a restricted" + err " network, bring Tailscale up so it can reach the tailnet broker, or have Bryan provision" + err " a reachable broker ingress. (Escape hatch, NO kill-switch: relaunch LARRY_AUTH_MODE=apikey.)" + exit 1 + ;; + esac +fi + # v0.7.0: HL7 v2.x schema for inline tab completion + /hl7 / /hl7-fields slash # commands. Sourced (not executed) so the bash assoc arrays live in our shell. # Silently no-ops on bash <4 (assoc arrays unavailable); the REPL still works, @@ -4636,7 +4768,19 @@ call_api() { return 1 fi local auth_args=() - if [ "$LARRY_AUTH_MODE" = "oauth" ]; then + if [ "$LARRY_AUTH_MODE" = "broker" ]; then + # BROKER RAIL (default): present a fresh short-lived broker token as a Bearer. + # No x-api-key — the real key is injected server-side at the broker. If the + # token can't be (re)minted, the deployment is unauthorized/unreachable: fail + # closed (return 1) rather than fall back to any baked credential. + local _btok; _btok=$(_broker_ensure_token); local _btrc=$? + if [ "$_btrc" != "0" ] || [ -z "$_btok" ]; then + err "call_api blocked: could not mint a broker token (deployment revoked or broker unreachable) — fail-closed." + [ "$_btrc" = "3" ] && [ "${_BROKER_LAST_PROFILE:-$LARRY_PROFILE}" = "phi" ] && _broker_on_disabled + return 1 + fi + auth_args=( -H "Authorization: Bearer $_btok" ) + elif [ "$LARRY_AUTH_MODE" = "oauth" ]; then local oauth_script="$LARRY_LIB_DIR/oauth.sh" local token="" oauth_stderr_file="" if [ -x "$oauth_script" ]; then @@ -4777,7 +4921,18 @@ call_api_stream() { return 1 fi local auth_args=() - if [ "$LARRY_AUTH_MODE" = "oauth" ]; then + if [ "$LARRY_AUTH_MODE" = "broker" ]; then + # BROKER RAIL (default): short-lived Bearer token; no x-api-key. Fail closed + # if it can't be minted (revoked / broker unreachable). Kept in lockstep with + # call_api's broker branch. + local _btok; _btok=$(_broker_ensure_token); local _btrc=$? + if [ "$_btrc" != "0" ] || [ -z "$_btok" ]; then + err "call_api_stream blocked: could not mint a broker token (revoked or broker unreachable) — fail-closed." + [ "$_btrc" = "3" ] && [ "${_BROKER_LAST_PROFILE:-$LARRY_PROFILE}" = "phi" ] && _broker_on_disabled + return 1 + fi + auth_args=( -H "Authorization: Bearer $_btok" ) + elif [ "$LARRY_AUTH_MODE" = "oauth" ]; then local oauth_script="$LARRY_LIB_DIR/oauth.sh" local token="" if [ -x "$oauth_script" ]; then @@ -7365,7 +7520,21 @@ main_loop() { /pwd) echo "$(pwd)"; continue ;; /env) printf '%s\n' "$CLOVERLEAF_CTX"; continue ;; /auth) printf '%sauth rail: %s (primary: %s)%s\n' "$C_BOLD" "$LARRY_AUTH_MODE" "$LARRY_PRIMARY_AUTH_MODE" "$C_RESET" - show_api_key_status + if [ "$LARRY_AUTH_MODE" = "broker" ]; then + printf ' broker: %s\n' "$LARRY_BROKER_URL" + printf ' deployment id: %s\n' "${LARRY_DEPLOYMENT_ID:-(unset)}" + printf ' profile: %s\n' "${_BROKER_LAST_PROFILE:-$LARRY_PROFILE}" + printf ' heartbeat: every %ss, fail-close after %s misses (misses=%s)\n' \ + "$LARRY_HEARTBEAT_INTERVAL" "$LARRY_HEARTBEAT_MAX_MISS" "${_BROKER_MISS_COUNT:-0}" + if command -v _broker_token_valid >/dev/null 2>&1 && _broker_token_valid; then + printf ' token: held (short-lived; never on disk)\n' + else + printf ' token: (none held right now — minted on next call)\n' + fi + printf ' %sNo sk-ant-… key is stored on this box — the kill-switch is ON.%s\n' "$C_DIM" "$C_RESET" + else + show_api_key_status + fi if [ "$LARRY_AUTH_MODE" = "oauth" ] && [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" status fi @@ -7811,6 +7980,31 @@ EOF # rule. First-turn suppression is enforced inside render_status_line # (returns silently when there is no header data yet). render_status_line + # v0.9.0: FAIL-CLOSED HEARTBEAT (broker mode). Before each real turn, if the + # poll interval has elapsed, re-confirm authorization. A disable mid-session + # (or N consecutive unreachable misses) refuses to continue — and for a + # profile:phi deployment runs the best-effort local PHI wipe — rather than + # billing another turn. The per-call broker token re-mint in call_api is the + # hard stop; this is the fast, cooperative in-REPL check on top. + if [ "$LARRY_AUTH_MODE" = "broker" ] && command -v _broker_heartbeat >/dev/null 2>&1; then + _hb_now=$(date +%s 2>/dev/null || echo 0) + if [ $(( _hb_now - _LARRY_LAST_HEARTBEAT )) -ge "$LARRY_HEARTBEAT_INTERVAL" ]; then + _broker_heartbeat; _hb_rc=$? + case "$_hb_rc" in + 0) _LARRY_LAST_HEARTBEAT="$_hb_now" ;; + 3) err "broker DISABLED this deployment mid-session — stopping." + _broker_on_disabled + break ;; + 4) _BROKER_MISS_COUNT=$(( _BROKER_MISS_COUNT + 1 )) + if [ "$_BROKER_MISS_COUNT" -ge "$LARRY_HEARTBEAT_MAX_MISS" ]; then + err "broker unreachable for $_BROKER_MISS_COUNT consecutive heartbeats — fail-closed, stopping." + break + else + warn "broker heartbeat miss ($_BROKER_MISS_COUNT/$LARRY_HEARTBEAT_MAX_MISS) — continuing for now." + fi ;; + esac + fi + fi add_user_text "$input" agent_turn "$system_prompt" || warn "turn ended with error" echo "" diff --git a/lib/broker.sh b/lib/broker.sh new file mode 100644 index 0000000..5e43335 --- /dev/null +++ b/lib/broker.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +# broker.sh — larry-broker client (the remote kill-switch client integration, +# Phase 3). Defines functions only; runs no code on source. +# +# WHY THIS EXISTS (Bryan, 2026-05-31): a deployed Cloverleaf-Larry on a client +# box (e.g. the Gundersen/Epic install) must NOT hold a long-lived sk-ant-… key +# Bryan would have to chase across the Anthropic console to kill. Instead the +# client holds a per-deployment ENROLLMENT SECRET, exchanges it for a SHORT-LIVED +# token from a broker Bryan controls (on .135), and routes every LLM call THROUGH +# the broker (/v1/messages) — the broker injects the real key server-side. Stop +# authorizing a deployment in the broker and it 401s and dies, with NO access to +# the box required. This is the DEFAULT rail for every Cloverleaf-Larry. +# +# Server contract (Mack's Phase-1 broker; /mnt/nas/docker/larry-broker): +# POST /enroll-mint {"deployment_id","enrollment_secret"} +# -> 200 {"token","expires_in","expires_at"} (authorized) +# -> 401 (unknown/bad/revoked) +# POST /v1/messages Authorization: Bearer (Anthropic Messages shape) +# -> proxied to Anthropic; 401 the INSTANT the deployment is revoked. +# GET /authorized?dep= (no auth) -> {authorized, profile, wipe_on_disable} +# +# FAIL-CLOSED is the whole point: if the client cannot CONFIRM authorization +# (disabled, OR N consecutive heartbeat misses), it REFUSES to run. For a +# profile:phi deployment it then runs a best-effort local PHI wipe (same +# secure-delete logic as uninstall-larry.sh) before exiting. "Can't confirm" is +# treated as "not authorized" — never as "assume OK and keep running". +# +# REACHABILITY (the critical design tension, flagged for Bryan): broker-mode +# means the client MUST reach the broker to function. The broker is LAN + +# Tailscale only (no public route). On an egress-restricted box (the Gundersen +# Cloudflare block that 28'd git.bjnoela.com), the client reaches the broker over +# TAILSCALE (LARRY_BROKER_URL=http://100.86.16.114:8181). If neither LAN nor +# Tailscale can reach the broker, broker-mode fail-closes = the agent will not +# run. That is a correct KILL state but a useless WORKING state, so a deployment +# on a locked-down network MUST have Tailscale (or a future hardened public +# broker ingress). See the README "Reachability" section. +# +# SOURCING NOTE: pure function defs; no set -e/-u/-o pipefail changes (the caller +# owns those). Listed in MANIFEST so it propagates + stays auditable. + +# ── Config (caller sets these before sourcing; sane defaults here) ─────────── +# LARRY_BROKER_URL broker base URL (LAN: http://192.168.20.135:8181 ; +# Tailscale: http://100.86.16.114:8181). Default = tailnet +# so an off-LAN client works out of the box. +# LARRY_DEPLOYMENT_ID this deployment's id in the broker registry. +# LARRY_ENROLL_SECRET the per-deployment enrollment secret (or in +# $LARRY_HOME/.enroll-secret, mode 0600). +# LARRY_PROFILE "default" | "phi". phi => wipe local PHI on disable. +# LARRY_HEARTBEAT_INTERVAL seconds between /authorized polls (default 60). +# LARRY_HEARTBEAT_MAX_MISS consecutive misses tolerated before fail-close +# (default 3). misses are unreachable broker; an +# explicit authorized:false fails closed IMMEDIATELY. + +LARRY_BROKER_URL="${LARRY_BROKER_URL:-http://100.86.16.114:8181}" +LARRY_DEPLOYMENT_ID="${LARRY_DEPLOYMENT_ID:-}" +LARRY_ENROLL_SECRET="${LARRY_ENROLL_SECRET:-}" +LARRY_PROFILE="${LARRY_PROFILE:-default}" +LARRY_HEARTBEAT_INTERVAL="${LARRY_HEARTBEAT_INTERVAL:-60}" +LARRY_HEARTBEAT_MAX_MISS="${LARRY_HEARTBEAT_MAX_MISS:-3}" + +# Runtime state (in-memory; the token never touches disk). +_BROKER_TOKEN="" +_BROKER_TOKEN_EXP=0 # unix epoch the token expires at +_BROKER_MISS_COUNT=0 # consecutive heartbeat misses +_BROKER_LAST_PROFILE="" # profile the broker last reported (authoritative) + +# _broker_strip_cr — defensive CR-strip (MobaXterm/Cygwin paste taints). +_broker_strip_cr() { local v="${1:-}"; printf '%s' "${v//$'\r'/}"; } + +# _broker_load_secret — resolve the enrollment secret from env or the 0600 file. +_broker_load_secret() { + if [ -n "$LARRY_ENROLL_SECRET" ]; then + LARRY_ENROLL_SECRET="$(_broker_strip_cr "$LARRY_ENROLL_SECRET")" + return 0 + fi + local f="${LARRY_HOME:-$HOME/.larry}/.enroll-secret" + if [ -f "$f" ]; then + LARRY_ENROLL_SECRET="$(_broker_strip_cr "$(cat "$f" 2>/dev/null)")" + LARRY_ENROLL_SECRET="${LARRY_ENROLL_SECRET//$'\n'/}" + fi + [ -n "$LARRY_ENROLL_SECRET" ] +} + +# _broker_json_field BODY KEY — extract a top-level JSON string/number/bool value +# WITHOUT requiring jq (the locked-down boxes may not have it). Prefers jq when +# present; falls back to a tolerant sed/grep. Returns the value on stdout. +_broker_json_field() { + local body="$1" key="$2" + if command -v jq >/dev/null 2>&1; then + # NB: do NOT use `// empty` — in jq the alternative operator treats a literal + # `false` (and `null`) as empty, so `"authorized": false` would parse as "" + # and the heartbeat would mis-classify a DISABLED deployment as an unreachable + # MISS (delaying fail-close past the miss budget and skipping the PHI wipe). + # Map an absent key to empty explicitly; render false/null/numbers verbatim. + local _jq; _jq="$(printf '%s' "$body" | jq -r --arg k "$key" 'if has($k) then .[$k] else "" end' 2>/dev/null)" + if [ -n "$_jq" ] || printf '%s' "$body" | jq -e --arg k "$key" 'has($k)' >/dev/null 2>&1; then + printf '%s' "$_jq"; return 0 + fi + fi + # Fallback: match "key": "value" OR "key": value (bool/number/null). + printf '%s' "$body" \ + | tr -d '\r\n' \ + | grep -oE "\"$key\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|true|false|null|[0-9]+)" \ + | head -1 \ + | sed -E "s/.*:[[:space:]]*//; s/^\"//; s/\"$//" +} + +# ── Enroll + mint ──────────────────────────────────────────────────────────── +# _broker_enroll_mint — exchange (deployment_id, enroll_secret) for a short-lived +# token. Sets _BROKER_TOKEN / _BROKER_TOKEN_EXP on success. The secret is fed via +# curl --data @- on stdin (off argv / the process table). Returns: +# 0 = minted (authorized) +# 3 = unauthorized (401: unknown id / bad secret / REVOKED — fail closed) +# 4 = unreachable (curl/network/DNS — fail closed after the miss budget) +_broker_enroll_mint() { + command -v curl >/dev/null 2>&1 || return 4 + _broker_load_secret || { _broker_log_err "no enrollment secret (set LARRY_ENROLL_SECRET or $LARRY_HOME/.enroll-secret)"; return 3; } + [ -n "$LARRY_DEPLOYMENT_ID" ] || { _broker_log_err "LARRY_DEPLOYMENT_ID is unset — cannot enroll"; return 3; } + + local url="$LARRY_BROKER_URL/enroll-mint" + local body code resp tmp + # Build the request body on a tmpfile (secret never on argv). + tmp="$(mktemp 2>/dev/null || echo "")" + if [ -n "$tmp" ]; then + printf '{"deployment_id":"%s","enrollment_secret":"%s"}' \ + "$LARRY_DEPLOYMENT_ID" "$LARRY_ENROLL_SECRET" > "$tmp" + resp="$(curl -sS --max-time 20 -w $'\n%{http_code}' \ + -H 'content-type: application/json' \ + --data-binary "@$tmp" "$url" 2>/dev/null)"; code=$? + rm -f "$tmp" + else + resp="$(curl -sS --max-time 20 -w $'\n%{http_code}' \ + -H 'content-type: application/json' \ + --data-binary "{\"deployment_id\":\"$LARRY_DEPLOYMENT_ID\",\"enrollment_secret\":\"$LARRY_ENROLL_SECRET\"}" \ + "$url" 2>/dev/null)"; code=$? + fi + [ "$code" != "0" ] && { _broker_log_err "broker unreachable at $url (curl rc=$code)"; return 4; } + + local http="${resp##*$'\n'}" payload="${resp%$'\n'*}" + if [ "$http" = "200" ]; then + local tok exp + tok="$(_broker_json_field "$payload" token)" + exp="$(_broker_json_field "$payload" expires_at)" + if [ -n "$tok" ]; then + _BROKER_TOKEN="$tok" + # expires_at is absolute epoch; if absent derive from expires_in. + if [ -n "$exp" ]; then + _BROKER_TOKEN_EXP="$exp" + else + local ein; ein="$(_broker_json_field "$payload" expires_in)" + _BROKER_TOKEN_EXP=$(( $(date +%s 2>/dev/null || echo 0) + ${ein:-60} )) + fi + _BROKER_MISS_COUNT=0 + return 0 + fi + _broker_log_err "broker 200 but no token in response" + return 4 + fi + if [ "$http" = "401" ]; then + return 3 # unknown / bad secret / REVOKED — fail closed, do not retry-loop + fi + _broker_log_err "broker enroll-mint HTTP $http (treating as unreachable)" + return 4 +} + +# _broker_token_valid — true if we hold a token with >20s of life left. +_broker_token_valid() { + [ -n "$_BROKER_TOKEN" ] || return 1 + local now; now=$(date +%s 2>/dev/null || echo 0) + [ "$_BROKER_TOKEN_EXP" -gt "$(( now + 20 ))" ] 2>/dev/null +} + +# _broker_ensure_token — return a live token on stdout, minting/refreshing if the +# current one is missing or near-expiry. Returns the mint rc (0/3/4) so the +# caller can fail closed on 3/4. +_broker_ensure_token() { + if _broker_token_valid; then printf '%s' "$_BROKER_TOKEN"; return 0; fi + _broker_enroll_mint; local rc=$? + [ "$rc" = "0" ] && printf '%s' "$_BROKER_TOKEN" + return $rc +} + +# ── Heartbeat (fail-closed authorization check) ────────────────────────────── +# _broker_heartbeat — GET /authorized?dep=. Updates _BROKER_LAST_PROFILE. +# Returns: +# 0 = authorized:true (run) +# 3 = authorized:false (DISABLED — fail closed immediately) +# 4 = unreachable / unparsable (a MISS — caller increments the miss budget) +_broker_heartbeat() { + command -v curl >/dev/null 2>&1 || return 4 + [ -n "$LARRY_DEPLOYMENT_ID" ] || return 3 + local url="$LARRY_BROKER_URL/authorized?dep=$LARRY_DEPLOYMENT_ID" + local resp code http payload + resp="$(curl -sS --max-time 12 -w $'\n%{http_code}' "$url" 2>/dev/null)"; code=$? + [ "$code" != "0" ] && return 4 + http="${resp##*$'\n'}"; payload="${resp%$'\n'*}" + [ "$http" = "200" ] || return 4 + local authd prof + authd="$(_broker_json_field "$payload" authorized)" + prof="$(_broker_json_field "$payload" profile)" + [ -n "$prof" ] && [ "$prof" != "null" ] && _BROKER_LAST_PROFILE="$prof" + case "$authd" in + true) _BROKER_MISS_COUNT=0; return 0 ;; + false) return 3 ;; + *) return 4 ;; # could not parse authorized => treat as a miss + esac +} + +# _broker_preflight_gate — the launch-time fail-closed gate. Polls /authorized +# once (with a tiny retry to tolerate a transient blip within the miss budget). +# On confirmed authorized:true => 0. On authorized:false => triggers PHI wipe (if +# phi) and returns 3. On unreachable past the budget => returns 4 (caller blocks; +# NO wipe on unreachable — we can't confirm a revoke, only that we can't reach +# home, so wiping on every network blip would be destructive). Bryan's rule: +# fail-closed = does not RUN; PHI wipe fires only on an explicit disable. +_broker_preflight_gate() { + local tries=0 max="$LARRY_HEARTBEAT_MAX_MISS" rc + while [ "$tries" -lt "$max" ]; do + _broker_heartbeat; rc=$? + case "$rc" in + 0) return 0 ;; # authorized — run + 3) _broker_on_disabled; return 3 ;; # explicit revoke — wipe(phi)+block + 4) tries=$(( tries + 1 )); [ "$tries" -lt "$max" ] && sleep 2 ;; + esac + done + return 4 # unreachable past the budget — fail closed (block), no wipe +} + +# _broker_on_disabled — invoked when the broker says authorized:false. For a +# phi profile, runs the best-effort local PHI wipe, then the caller blocks/exits. +_broker_on_disabled() { + _broker_log_err "deployment '$LARRY_DEPLOYMENT_ID' is DISABLED in the broker — refusing to run." + local prof="${_BROKER_LAST_PROFILE:-$LARRY_PROFILE}" + if [ "$prof" = "phi" ]; then + _broker_log_err "profile=phi — running best-effort local PHI wipe (see uninstall-larry.sh for the guaranteed path)." + _broker_phi_wipe + fi +} + +# ── Best-effort PHI wipe (reuses uninstall-larry.sh's secure-delete logic) ─── +# _broker_secure_delete FILE — shred -u -z -n3 if available; else overwrite then +# rm (best-effort on Windows/CoW/SSD); else plain rm. Echoes the method achieved. +# BYTE-FOR-BYTE the same approach as uninstall-larry.sh secure_delete(). +_broker_secure_delete() { + local f="$1" + [ -f "$f" ] || { echo "absent"; return 0; } + if command -v shred >/dev/null 2>&1; then + if shred -u -z -n 3 "$f" 2>/dev/null; then echo "shred"; return 0; fi + if shred -u "$f" 2>/dev/null; then echo "shred"; return 0; fi + fi + local sz; sz="$(wc -c < "$f" 2>/dev/null || echo 0)" + if [ "${sz:-0}" -gt 0 ] 2>/dev/null; then + if command -v dd >/dev/null 2>&1; then + dd if=/dev/zero of="$f" bs=1 count="$sz" conv=notrunc 2>/dev/null || true + [ -r /dev/urandom ] && dd if=/dev/urandom of="$f" bs=1 count="$sz" conv=notrunc 2>/dev/null || true + else + : > "$f" 2>/dev/null || true + fi + fi + rm -f "$f" 2>/dev/null && echo "overwrite" || echo "FAILED" +} + +# _broker_phi_wipe — securely delete the known finite list of cleartext-PHI +# artifacts under $LARRY_HOME. Same target list as uninstall-larry.sh collect_phi() +# PLUS the per-client credentials (.api-key/.env/.enroll-secret) and broker token +# state, since a disabled deployment should leave nothing usable behind. This is +# BEST-EFFORT (Finding 4 of the design brief): it only fires if the agent runs +# again while online and the script is intact; it cannot touch a powered-off box, +# and deletion is not guaranteed-unrecoverable on SSD/CoW/Windows. The guaranteed +# path remains the machine owner running uninstall-larry.sh. +_broker_phi_wipe() { + local lh="${LARRY_HOME:-$HOME/.larry}" + # HARD SAFETY GUARD (mirrors uninstall-larry.sh): never operate on an empty / + # root / $HOME path. + local norm; norm="$(printf '%s' "$lh" | sed 's:/*$::')" + case "$norm" in + ""|"/"|"/root"|"/home"|"/Users"|"/usr"|"/etc"|"/var"|"/bin"|"/tmp"|"/.larry") + _broker_log_err "PHI wipe refused: LARRY_HOME ('$lh') resolves to a dangerous path."; return 1 ;; + esac + case "$norm" in */.larry|*larry*) : ;; *) + _broker_log_err "PHI wipe refused: LARRY_HOME ('$lh') doesn't look like a Larry install dir."; return 1 ;; + esac + [ -n "${HOME:-}" ] && [ "$norm" = "$(printf '%s' "$HOME" | sed 's:/*$::')" ] && { + _broker_log_err "PHI wipe refused: LARRY_HOME equals \$HOME."; return 1; } + + local targets=() f + # Cleartext-PHI artifacts (per design brief Finding 6 + decommission §7.3). + for f in "$lh/log/auto-phi.log" "$lh/sanitize/lookup.tsv"; do + [ -f "$f" ] && targets+=("$f") + done + # Session transcripts (may contain PHI). + if [ -d "$lh/sessions" ]; then + if command -v find >/dev/null 2>&1; then + while IFS= read -r f; do [ -n "$f" ] && targets+=("$f"); done \ + < <(find "$lh/sessions" -type f -name '*.log.md' 2>/dev/null) + else + local _ng; _ng="$(shopt -p nullglob 2>/dev/null || true)"; shopt -s nullglob 2>/dev/null || true + for f in "$lh"/sessions/*.log.md "$lh"/sessions/**/*.log.md; do [ -f "$f" ] && targets+=("$f"); done + eval "$_ng" 2>/dev/null || true + fi + fi + # Credentials — a disabled deployment must not leave usable secrets. (In broker + # mode there is no .api-key by design, but a prior apikey-mode install may have + # left one; the enroll secret + any legacy key go too.) + for f in "$lh/.enroll-secret" "$lh/.api-key" "$lh/.env" "$lh/.oauth.json"; do + [ -f "$f" ] && targets+=("$f") + done + + local shredded=0 best=0 failed=0 method + for f in "${targets[@]}"; do + method="$(_broker_secure_delete "$f")" + case "$method" in + shred) shredded=$(( shredded + 1 )) ;; + overwrite) best=$(( best + 1 )) ;; + absent) : ;; + *) failed=$(( failed + 1 )) ;; + esac + done + # Scrub the in-memory token too. + _BROKER_TOKEN=""; _BROKER_TOKEN_EXP=0 + if command -v shred >/dev/null 2>&1; then + _broker_log_err "PHI wipe: $shredded shredded, $best best-effort, $failed failed." + else + _broker_log_err "PHI wipe (no 'shred' on this platform): $best removed best-effort, $failed failed." + _broker_log_err " Treat the disk as possibly still holding PHI remnants; the guaranteed delete is the machine owner running uninstall-larry.sh." + fi + return 0 +} + +# _broker_log_err — route through larry's err() if present, else stderr. Never +# logs a token or secret. +_broker_log_err() { + if command -v err >/dev/null 2>&1; then err "broker: $*"; else printf 'broker: %s\n' "$*" >&2; fi +}