diff --git a/docs/runbooks/althing-deploy.md b/docs/runbooks/althing-deploy.md index 38a0454..15cc528 100644 --- a/docs/runbooks/althing-deploy.md +++ b/docs/runbooks/althing-deploy.md @@ -1,4 +1,4 @@ -# althing deploy — four surfaces +# althing deploy — six surfaces **`scripts/deploy-althing.sh`** does all four. `--check` reports drift without touching anything. This page is the why. @@ -13,6 +13,8 @@ before anyone noticed, on 2026-09-01. | 2 | **herald** | `systemctl --user restart althing-po-herald` | new guards live here; nothing takes effect until it restarts | | 3 | **skill** | `scripts/sync_skill.sh` → `~/.agents/skills/althing/SKILL.md` | covered by its own `--check` | | 4 | **plugin** | repo `plugin/` → marketplace dir → Claude Code cache | **two hops, neither was automated** | +| 5 | **per-seat route** | `althing-route declare` on each seat | a channel change needs a re-declare; the plugin's SessionStart hook does it for CC seats | +| 6 | **`~/.claude/settings.json`** | `crossSessionInbound: "accept"` | **outside the althing repo entirely — no version can carry it** | ## ⚠ `uv tool install .` without `--force` is a silent no-op @@ -181,6 +183,53 @@ channel before the herald restarts goes silently pull-only. `uv tool install (whose SessionStart hook declares the route) last, which is what keeps the window to a couple of seconds. +## ⚠ Surface 6 — `crossSessionInbound`, and it is not in the althing repo + +**A cc poke to a default-configured seat is HELD, not delivered.** Claude Code +auto-delivers an inbound cross-session message only when the sender's +permission-mode class matches the receiver's, and **a sender that asserts no +class is held**. The herald is a daemon and asserts none, deliberately. What +the operator sees instead of a delivery: + + Held peer message — from an unidentified session [verified pid ]; + preview: «ALTHING-HERALD althing: you have mail ...» — not delivered to + Claude (1 held). The sender did not attest its permission mode and this + session bypasses prompts. + +Claude Code verified the herald through `SO_PEERCRED` and then correctly +declined to let it speak, because it would not say what it was. + +**The fix is one key in `~/.claude/settings.json`:** + + "crossSessionInbound": "accept" + +Set on nh3-dev 2026-09-02. The operator's reasoning is the part to keep: *the +herald can only reach local seats, so bypass is the correct authorization type; +when the guard was not there, that was our default posture, including the pane +poke.* A pane poke types into a session and presses Enter — bypass-level access +by any measure, and what we had been doing all along. **The socket channel is +strictly narrower than what it replaces**, so accepting here states the existing +trust boundary rather than widening it. + +⚠ **There is no attestation the herald could send instead.** Four probes +established that Claude Code identifies a sender by verified pid against the +session registry and reads that session's *live runtime* permission mode; a +daemon has none and the registry has no field for one. A `from_mode` field on a +`type:"user"` frame is not consulted at all — it belongs to the control actions. +Adding it would be shipping a field nothing reads. + +**Cost without the setting is smaller than it looks:** the correspondent record +is in-memory session state, so the hold is **first-contact, not per-message** — +one approval per seat per session lifetime, and a long-lived pane pays it once. + +⚠ **Why this surface is the dangerous one.** A seat without it is declared, +reachable and green, and its pokes go to a human instead of to the session. +That is the same shape as the SessionStart hook that was never deployed: +everything reports success, nothing arrives. `deploy-althing.sh` reads the key +and reports it on every run and on `--check` — and **deliberately never sets +it.** That file is the operator's permission configuration; a deploy script +that edits it is a deploy script granting itself trust. + ## Rollback uv tool install althing-core==3.1.2 diff --git a/scripts/deploy-althing.sh b/scripts/deploy-althing.sh index d43ebad..4468744 100755 --- a/scripts/deploy-althing.sh +++ b/scripts/deploy-althing.sh @@ -6,7 +6,7 @@ # scripts/deploy-althing.sh --check report drift, change nothing # # ───────────────────────────────────────────────────────────────────────────── -# WHY FOUR +# WHY FOUR — AND WHY THE COUNT IS NOW SIX # # 1. uv tool install --force . the 7 binaries (postbox, althing-listen, # althing-route, althing-po-herald, ...) @@ -84,6 +84,33 @@ plugin_content_drift() { return 1 } +# ── SURFACE 6: ~/.claude/settings.json crossSessionInbound ──────────────────── +# +# The only surface that is NOT in the althing repo, so a version check cannot +# reach it. Claude Code holds an inbound cross-session message unless the +# sender's permission-mode class matches yours, and a sender that asserts no +# class is held. The herald is a daemon and asserts none — deliberately — so on +# a default-configured seat a cc poke is HELD and the notice goes to whoever is +# looking at the pane instead of to the session. +# +# The failure shape is the expensive one: the seat is declared, reachable and +# green, and nothing arrives. Same family as the SessionStart hook that was +# never deployed. +# +# ⚠ REPORT ONLY, NEVER SET. This file is the operator's permission +# configuration; a deploy script that edits it is a deploy script that grants +# itself trust. Print what is there and let a human decide. +settings_inbound() { + python3 - <<'EOF' 2>/dev/null || echo "unreadable" +import json, pathlib +p = pathlib.Path.home() / ".claude/settings.json" +try: + print(json.loads(p.read_text()).get("crossSessionInbound", "UNSET")) +except Exception: + print("unreadable") +EOF +} + step "state" say " repo $(git -C "$REPO" describe --tags --always) $(git -C "$REPO" diff --quiet && echo clean || echo DIRTY)" say " installed tool $(uv tool list 2>/dev/null | awk '/^althing-core/{print $2}')" @@ -91,6 +118,14 @@ say " repo plugin $(repo_plugin_version)" say " marketplace $(market_plugin_version)" say " CC cache $(cache_versions)" plugin_content_drift || true +inbound="$(settings_inbound)" +say " cc inbound crossSessionInbound=$inbound (~/.claude/settings.json)" +if [[ "$inbound" != "accept" ]]; then + say " ⚠ cc pokes to a seat on this box are HELD at first contact, not delivered." + say " The seat still looks declared, reachable and healthy. Set" + say " crossSessionInbound: \"accept\" BY HAND — this script will not" + say " edit the operator's permission configuration." +fi if (( CHECK )); then say "" @@ -155,5 +190,6 @@ if ! plugin_content_drift; then say " bookkeeping. Check the diff above for whether it is load-bearing." fi say " herald $(systemctl --user is-active althing-po-herald)" +say " cc inbound crossSessionInbound=$(settings_inbound)" say "" say "⚠ RESTART Claude Code to load the new plugin hooks. A running session keeps the old ones."