v0.9.0: broker mode is the DEFAULT — wire the remote kill-switch into every Cloverleaf-Larry

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 <id> 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) <noreply@anthropic.com>
This commit is contained in:
bj 2026-05-31 23:10:09 -07:00
parent 6b45543652
commit ea9f4c2399
6 changed files with 695 additions and 27 deletions

View File

@ -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 <id> 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
<short-lived-token>`, 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

View File

@ -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

View File

@ -1 +1 @@
0.8.34
0.9.0

View File

@ -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=<paste> \
# 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 <id> ... ,"
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"

228
larry.sh
View File

@ -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 <id> 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"
[ -n "${ANTHROPIC_API_KEY:-}" ] || LARRY_AUTH_MODE="" # chose apikey but none set → first-run prompt
else
LARRY_AUTH_MODE="" # no key yet → first-run prompt guides to /set-api-key
fi
# 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"
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 ""

334
lib/broker.sh Normal file
View File

@ -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 <token> (Anthropic Messages shape)
# -> proxied to Anthropic; 401 the INSTANT the deployment is revoked.
# GET /authorized?dep=<id> (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=<id>. 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
}