From 2378665eafc99c3c89d524317a171052d9133da9 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 26 Jul 2026 04:19:09 +0000 Subject: [PATCH] feat(wake): W7 A10 idempotent installer + mosaic-wake.service (component-manifest, Gate-A, blank-reset retire, snapshot-guard, fail-closed install-validate) (#911) Co-authored-by: jason.woltje Co-committed-by: jason.woltje --- packages/mosaic/framework/install.sh | 49 ++ .../systemd/user/mosaic-wake.service | 31 ++ .../mosaic/framework/tools/wake/beacon.sh | 87 +++- .../mosaic/framework/tools/wake/manifest.txt | 23 +- .../framework/tools/wake/test-wake-beacon.sh | 64 +++ .../framework/tools/wake/test-wake-install.sh | 249 ++++++++++ .../framework/tools/wake/wake-install.sh | 444 ++++++++++++++++++ packages/mosaic/package.json | 2 +- .../src/framework/manifest-parity.spec.ts | 11 + 9 files changed, 951 insertions(+), 9 deletions(-) create mode 100644 packages/mosaic/framework/systemd/user/mosaic-wake.service create mode 100755 packages/mosaic/framework/tools/wake/test-wake-install.sh create mode 100755 packages/mosaic/framework/tools/wake/wake-install.sh diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 87db6cd3..7d262a24 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -39,10 +39,19 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" # never an environment variable — so this opt-out can never sit silently # inherited in a shell profile. See #869 Point-1 C2. ALLOW_INACTIVE_ENFORCEMENT=0 +# Component-scoped install (#892 W7): `install.sh --component ` runs an +# additive, self-contained component installer and EXITS — it never enters the +# full-framework sync below and never modifies framework-manifest ownership +# behavior (#869: the diff is ADDITIVE). Parsed as a two-token flag here. +COMPONENT="" +_prev_arg="" for _arg in "$@"; do case "$_arg" in --allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;; + --component=*) COMPONENT="${_arg#--component=}" ;; esac + [[ "$_prev_arg" == "--component" ]] && COMPONENT="$_arg" + _prev_arg="$_arg" done # Shared framework path-ownership manifest reader (#791). Parity with @@ -605,6 +614,46 @@ run_migrations() { fi } +# ═══════════════════════════════════════════════════════════════════════════════ +# Component-scoped install (#892 W7) — additive early dispatch. +# ═══════════════════════════════════════════════════════════════════════════════ +# `install.sh --component ` delegates to the component's own idempotent, +# fail-closed installer and EXITS. This path is ADDITIVE (#869): it does NOT run +# the full-framework sync, does NOT alter framework-manifest ownership behavior, +# and touches NOTHING the #869 install-ordering-guard covers (no runtime-asset +# linking, no lease-enforcement hook wiring). Each component installer is +# INTERSECTED-AND-VALIDATED against the single SSOT framework-manifest.txt, so a +# component manifest can never authorize a write outside framework ownership. +run_component_install() { + local name="$1" + case "$name" in + wake) + local wi="$SOURCE_DIR/tools/wake/wake-install.sh" + if [[ ! -x "$wi" && ! -f "$wi" ]]; then + fail "Component 'wake' installer not found at $wi" + exit 1 + fi + step "Installing Mosaic component: wake" + WAKE_INSTALL_SOURCE="$SOURCE_DIR" WAKE_INSTALL_TARGET="$TARGET_DIR" \ + bash "$wi" install + ;; + "") + fail "--component requires a name (e.g. --component wake)." + exit 1 + ;; + *) + fail "Unknown component '$name'. Supported: wake." + exit 1 + ;; + esac +} + +if [[ -n "$COMPONENT" ]]; then + mkdir -p "$TARGET_DIR" + run_component_install "$COMPONENT" + exit 0 +fi + # ═══════════════════════════════════════════════════════════════════════════════ # Main # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/packages/mosaic/framework/systemd/user/mosaic-wake.service b/packages/mosaic/framework/systemd/user/mosaic-wake.service new file mode 100644 index 00000000..e1ff4232 --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-wake.service @@ -0,0 +1,31 @@ +[Unit] +# Mosaic wake DETECTOR daemon (A1/W7 of the wake canon, EPIC #892). A LONG-LIVED +# single-instance detector: tools/wake/detector.sh run. This is a SERVICE, not a +# timer — the per-class SLO lives INSIDE the daemon's run-loop (WAKE_DETECTOR_INTERVAL +# poll cadence + the per-cycle off-host beacon emit), never as a systemd +# OnUnitActiveSec interval. The blank-reset cadence idiom therefore does NOT apply +# to this unit; it applies only to the legacy mosaic-heartbeat@ timer during retire. +Description=Mosaic wake detector daemon (framework tools/wake/detector.sh run) +After=default.target + +[Service] +Type=simple +# Strip loader / noninteractive-shell controls before ExecStart, matching the +# lease-broker and tmux fleet units in this same directory (defense-in-depth +# against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment). +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +# Operator-owned runtime configuration. This EnvironmentFile carries only the +# WAKE_* NAMES and the pluggable adapter COMMANDS (the off-host beacon/alarm sink +# and the HMAC key NAME) — NEVER the HMAC key material and NEVER the alarm +# endpoint value. Both are resolved BY NAME at runtime via load_credentials, so +# no secret and no endpoint is ever written into this unit. The installer's +# fail-closed install-validate (wake-install.sh validate-targets) is what proves +# the required names are configured + reachable BEFORE this unit is enabled; the +# '-' prefix keeps a missing file from masking that dedicated validation. +EnvironmentFile=-%h/.config/mosaic/wake/detector.env +ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/detector.sh run +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/packages/mosaic/framework/tools/wake/beacon.sh b/packages/mosaic/framework/tools/wake/beacon.sh index 4b5d0786..bdfc2e5d 100755 --- a/packages/mosaic/framework/tools/wake/beacon.sh +++ b/packages/mosaic/framework/tools/wake/beacon.sh @@ -72,6 +72,26 @@ SEQ_FILE="$BEACON_DIR/seq" # default keeps the primitive self-contained + testable. RECEIVED_FILE="${WAKE_BEACON_RECEIVED:-$BEACON_DIR/received.json}" +# The non-circular HMAC signer (A5/W3). Reused VERBATIM here for beacon +# authentication (W7 monitor-integration hardening): emit signs the beacon core +# via `sign.sh sign-digest`, record verifies via the existing `sign.sh verify` +# path. Both resolve the key BY NAME (load_credentials store) — never inline. +SIGN_SH="$SCRIPT_DIR/sign.sh" + +# Beacon HMAC key NAME (by-name, never the key itself). Signing is engaged ONLY +# when a key name is configured — WAKE_BEACON_HMAC_KEY_NAME, else WAKE_HMAC_KEY_NAME. +# When configured, record REQUIRES an authentic, bound envelope (a spoofed or +# unsigned beacon is REJECTED). When unset, the legacy unsigned path is preserved +# (W7 install-validate is what guarantees a key is configured in production). +_beacon_key_name() { printf '%s' "${WAKE_BEACON_HMAC_KEY_NAME:-${WAKE_HMAC_KEY_NAME:-}}"; } + +# Canonical beacon CORE (stdin: a full beacon record; stdout: sorted-key JSON with +# the signature envelope and the monitor-stamped ingested_ts stripped). emit signs +# this exact projection; record recomputes it identically, so the MAC binds the +# beacon's payload and an envelope cannot be lifted onto a different beacon. +_beacon_core() { jq -cS 'del(.beacon_envelope) | del(.ingested_ts)'; } +_beacon_sha256() { openssl dgst -sha256 -r 2>/dev/null | awk '{print $1}'; } + _need_jq() { command -v jq >/dev/null 2>&1 || { echo "beacon.sh: jq is required" >&2 @@ -215,6 +235,22 @@ cmd_emit() { independence:$independence, degraded:$degraded} + (if $supervision_root == "" then {} else {supervision_root:$supervision_root} end)')" + # HMAC-SIGN the beacon (W7 monitor-integration hardening) when a key name is + # configured. The signer is the existing non-circular sign.sh, keyed BY NAME — + # emit inlines no key. The signed envelope binds the beacon core (seq/emit_ts/ + # content_hash), so the off-host monitor can reject a spoofed beacon at record. + local beacon_key; beacon_key="$(_beacon_key_name)" + if [ -n "$beacon_key" ]; then + command -v openssl >/dev/null 2>&1 || { echo "beacon.sh: openssl is required to HMAC-sign the beacon (key '$beacon_key' configured)." >&2; exit 3; } + local core envelope + core="$(printf '%s' "$record" | _beacon_core)" + if ! envelope="$(printf '%s' "$core" | "$SIGN_SH" sign-digest --key-name "$beacon_key" --observed-seq "$seq" --emit-ts "$emit_ts" 2>/dev/null)" || [ -z "$envelope" ]; then + echo "beacon.sh: FAIL LOUD — could not HMAC-sign the beacon with key '$beacon_key' (resolve it by-name in the credential store). Refusing to emit an UNSIGNED beacon when signing is configured." >&2 + exit 1 + fi + record="$(printf '%s' "$record" | jq -c --argjson env "$envelope" '. + {beacon_envelope:$env}')" + fi + # DEGRADED beacons are FLAGGED loudly (§1.3) — an isolated host must never # silently present a weaker different-supervision-root leg as full independence. if [ "$degraded" -eq 1 ]; then @@ -262,6 +298,38 @@ cmd_record() { ;; esac + # HMAC-VERIFY the beacon (W7 monitor-integration hardening) — additive to the + # integer/monotonicity validation above. When a key name is configured, a beacon + # is accepted ONLY if it carries an envelope that is (1) AUTHENTIC under the + # by-name key (via the existing sign.sh verify path) and (2) BOUND to THIS beacon + # (its signed content_hash == sha256(core) and its seq/emit_ts match). A spoofed + # or unsigned beacon is REJECTED, so a forged liveness signal cannot roll the + # monitor's dead-man clock forward. + local beacon_key; beacon_key="$(_beacon_key_name)" + if [ -n "$beacon_key" ]; then + command -v openssl >/dev/null 2>&1 || { echo "beacon.sh record: openssl is required to HMAC-verify the beacon (key '$beacon_key' configured)." >&2; exit 3; } + local env + env="$(printf '%s' "$incoming" | jq -c '.beacon_envelope // empty' 2>/dev/null)" + if [ -z "$env" ]; then + echo "beacon.sh record: FAIL LOUD — beacon carries no signature envelope but signing is configured (key '$beacon_key'). Rejecting an UNSIGNED/spoofed beacon." >&2 + exit 2 + fi + if ! printf '%s' "$env" | "$SIGN_SH" verify --key-name "$beacon_key" >/dev/null 2>&1; then + echo "beacon.sh record: FAIL LOUD — beacon signature is NOT authentic under key '$beacon_key' (bad HMAC). Rejecting a spoofed beacon." >&2 + exit 2 + fi + local core core_sha env_chash env_seq env_ts + core="$(printf '%s' "$incoming" | _beacon_core)" + core_sha="$(printf '%s' "$core" | _beacon_sha256)" + env_chash="$(printf '%s' "$env" | jq -r '.signed.content_hash // ""')" + env_seq="$(printf '%s' "$env" | jq -r '.signed.observed_seq // ""')" + env_ts="$(printf '%s' "$env" | jq -r '.signed.emit_ts // ""')" + if [ "$core_sha" != "$env_chash" ] || [ "$env_seq" != "$in_seq" ] || [ "$env_ts" != "$in_ts" ]; then + echo "beacon.sh record: FAIL LOUD — beacon signature is valid but NOT bound to this beacon (envelope lifted onto altered fields). Rejecting a spoofed beacon." >&2 + exit 2 + fi + fi + mkdir -p "$(dirname "$RECEIVED_FILE")" # MONOTONIC receiver: keep the highest-seq beacon. A lower-or-equal seq is a # stale/replayed beacon and is IGNORED (does not roll the liveness clock back). @@ -274,7 +342,12 @@ cmd_record() { echo "beacon.sh record: ignoring stale/replayed beacon seq $in_seq (<= recorded $last_seq)" >&2 exit 0 fi - printf '%s\n' "$incoming" | _atomic_write "$RECEIVED_FILE" + # Stamp a MONITOR-SIDE ingested_ts at receive time (W7 monitor-integration + # hardening). Staleness (check) is computed from THIS receive-time, never the + # host-supplied emit_ts — so a host shipping a far-future emit_ts to defer its + # own staleness cannot fool the off-host dead-man clock. + local ingested_ts; ingested_ts="$(date +%s)" + printf '%s' "$incoming" | jq -c --argjson t "$ingested_ts" '. + {ingested_ts:$t}' | _atomic_write "$RECEIVED_FILE" } # --- off-host monitor side: the DEAD-MAN / ABSENCE check ------------------- @@ -342,16 +415,20 @@ cmd_check() { fi local last_ts last_seq host_id age - last_ts="$(jq -r '.emit_ts // empty' "$RECEIVED_FILE" 2>/dev/null)" + # Staleness clock = the MONITOR's receive-time (ingested_ts), stamped by record. + # Fall back to emit_ts only for a legacy record written before ingested_ts + # existed. A host-supplied far-future emit_ts therefore CANNOT defer staleness: + # once ingested_ts is present it governs, and emit_ts is never used for age. + last_ts="$(jq -r '.ingested_ts // .emit_ts // empty' "$RECEIVED_FILE" 2>/dev/null)" last_seq="$(jq -r '.beacon_seq // empty' "$RECEIVED_FILE" 2>/dev/null)" host_id="$(jq -r '.host_id // "unknown"' "$RECEIVED_FILE" 2>/dev/null)" case "$last_ts" in '' | *[!0-9]*) local payload payload="$(jq -cn --argjson now "$now" --argjson slo "$slo" \ - '{kind:"beacon-absence-alarm", reason:"received beacon has no valid emit_ts (corrupt)", + '{kind:"beacon-absence-alarm", reason:"received beacon has no valid receive timestamp (corrupt)", detected_ts:$now, slo_seconds:$slo}')" - _fire_alarm "corrupt received beacon (no emit_ts)" "$payload" + _fire_alarm "corrupt received beacon (no ingested_ts/emit_ts)" "$payload" ;; esac @@ -370,7 +447,7 @@ cmd_check() { --argjson age "$age" \ --argjson slo "$slo" \ '{kind:"beacon-absence-alarm", reason:"beacon stale past SLO", - host_id:$host_id, last_beacon_seq:$last_seq, last_emit_ts:$last_ts, + host_id:$host_id, last_beacon_seq:$last_seq, last_received_ts:$last_ts, detected_ts:$now, age_seconds:$age, slo_seconds:$slo}')" _fire_alarm "beacon stale (${age}s > SLO ${slo}s), host=$host_id last_seq=${last_seq:-?}" "$payload" fi diff --git a/packages/mosaic/framework/tools/wake/manifest.txt b/packages/mosaic/framework/tools/wake/manifest.txt index 64c0644c..5debfecf 100644 --- a/packages/mosaic/framework/tools/wake/manifest.txt +++ b/packages/mosaic/framework/tools/wake/manifest.txt @@ -17,8 +17,15 @@ # 0.4.0 W5 — synthetic-canary FN-oracle + source-parity reconciler. # 0.5.0 W6 — off-host dead-man beacon emitter + pluggable alarm-sink adapter # + beacon-absence alarm (fail-loud on unconfigured/unreachable). +# 0.6.0 W7 — A10 idempotent, fail-closed component installer (Gate-A +# intersect+validate against the framework-manifest SSOT), the +# mosaic-wake.service detector daemon, the blank-reset retire idiom +# for the legacy heartbeat timer + snapshot-guard, and fail-closed +# alarm-target/HMAC-key install-validation. Also folds in the two W6 +# monitor-integration observations (monitor-side ingested_ts +# staleness + beacon HMAC-verify at record). component=wake -version=0.5.0 +version=0.6.0 # Watch-list schema this component consumes, and the INCLUSIVE range of # schema_version values it supports. A wake-watch-list.json whose schema_version @@ -58,5 +65,15 @@ schema_max=1 # A same-host sibling is REJECTED as non-independent; an # isolated host degrades to a FLAGGED different-supervision-root # beacon; capture-pane is a liveness HINT only. (W6) -# Out of scope (later waves): installer (W7 wires + install-validates the beacon -# target that beacon.sh's fail-loud primitive is designed for). +# wake-install.sh A10 — idempotent, fail-closed COMPONENT installer. Selects the +# component file set and INTERSECTS-AND-VALIDATES it against the +# single SSOT framework-manifest.txt (Gate A) — this VERSION +# manifest authorizes no path. Ships the blank-reset retire +# idiom (exactly-one OnUnitActiveUSec) for the legacy heartbeat +# timer, the snapshot-guard (no reap without a snapshot), and +# fail-closed alarm-target + HMAC-key install-validation (the +# installer wires + install-validates the beacon target that +# beacon.sh's fail-loud primitive is designed for). (W7) +# Companion (framework subtree, not under tools/wake/): systemd/user/mosaic-wake.service +# — the long-lived detector daemon unit (per-class SLO lives in +# the daemon, NOT a systemd interval). (W7) diff --git a/packages/mosaic/framework/tools/wake/test-wake-beacon.sh b/packages/mosaic/framework/tools/wake/test-wake-beacon.sh index 7b854705..b87dbe68 100755 --- a/packages/mosaic/framework/tools/wake/test-wake-beacon.sh +++ b/packages/mosaic/framework/tools/wake/test-wake-beacon.sh @@ -271,6 +271,70 @@ echo "== B10: fresh beacon within SLO -> ALIVE (exit 0, no alarm) ==" [ ! -s "$ALARM_OUT" ] || fail_msg "B10: a fresh beacon must NOT fire an alarm" ) && ok +# ── W7 monitor-integration hardening (folded-in W6 observations, #910 review) ── + +echo "== B11: staleness from monitor ingested_ts -> a far-future emit_ts STILL goes stale ==" +( + H="$(fresh_home b11)" + export ALARM_OUT="$H/alarm.json" + export WAKE_BEACON_RECEIVED="$H/received.json" + export WAKE_ALARM_SINK_CMD="$CAPTURE_ALARM" + now="$(date +%s)" + # A host ships a FAR-FUTURE emit_ts (now + 100000s) to try to defer staleness. + # record stamps the monitor's own ingested_ts; check must use THAT, not emit_ts. + jq -cn --argjson ts "$((now + 100000))" \ + '{kind:"wake-beacon", beacon_seq:9, emit_ts:$ts, host_id:"h", independence:"off-host", degraded:false}' \ + | "$BEACON" record + # Simulate the monitor having received it 100s ago (ingested_ts backdated) while + # the host's emit_ts stays far in the future. Under emit_ts-based staleness this + # would read as ALIVE (age hugely negative); under receive-time it is STALE. + jq -c --argjson ing "$((now - 100))" '.ingested_ts = $ing' "$WAKE_BEACON_RECEIVED" >"$H/tmp" && mv "$H/tmp" "$WAKE_BEACON_RECEIVED" + out="$("$BEACON" check --slo-seconds 5 2>&1)"; rc=$? + [ "$rc" -eq 1 ] || fail_msg "B11: a far-future emit_ts must NOT defer staleness — receive-time governs (expected absence exit 1); got $rc [$out]" + echo "$out" | grep -qi 'ALARM FIRED' || fail_msg "B11: the receive-time-stale beacon must fire the absence alarm [$out]" + jq -e '.age_seconds >= 100' "$ALARM_OUT" >/dev/null 2>&1 || fail_msg "B11: staleness age must be measured from ingested_ts (>=100s), not emit_ts [$(cat "$ALARM_OUT" 2>/dev/null)]" +) && ok + +echo "== B12: HMAC-verify at record -> a spoofed (bad-sig) beacon is REJECTED ==" +if ! command -v openssl >/dev/null 2>&1; then + echo "SKIP: openssl not available" >&2 +else +( + H="$(fresh_home b12)" + export WAKE_STATE_HOME="$H" + export MOSAIC_CREDENTIALS_FILE="$H/credentials.json" + export WAKE_BEACON_HMAC_KEY_NAME="beacon" + jq -cn --arg k "test-beacon-hmac-key-do-not-echo" '{wake:{hmac_keys:{"beacon":$k}}}' >"$MOSAIC_CREDENTIALS_FILE" + export WAKE_BEACON_INDEPENDENCE="off-host" + # A capturing sink that keeps the exact shipped (signed) beacon record. + SHIPPED="$H/shipped.json" + SINK="$H/sink.sh"; printf '#!/usr/bin/env bash\ncat >"%s"\n' "$SHIPPED" >"$SINK"; chmod +x "$SINK" + export WAKE_BEACON_SINK_CMD="$SINK" + export WAKE_BEACON_RECEIVED="$H/received.json" + "$BEACON" emit >/dev/null 2>&1 || fail_msg "B12: a signed emit must succeed with a by-name key" + jq -e '.beacon_envelope' "$SHIPPED" >/dev/null 2>&1 || fail_msg "B12: a configured key must produce a signed beacon_envelope [$(cat "$SHIPPED" 2>/dev/null)]" + # (a) the authentic signed beacon is ACCEPTED at record. + "$BEACON" record <"$SHIPPED" >/dev/null 2>&1 || fail_msg "B12: an authentic signed beacon must be accepted at record" + [ -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "B12: the authentic beacon must be stored" + # (b) a SPOOFED beacon (fields altered, envelope lifted) is REJECTED. + rm -f "$WAKE_BEACON_RECEIVED" + spoof="$H/spoof.json" + jq -c '.beacon_seq = 99999 | .host_id = "attacker"' "$SHIPPED" >"$spoof" + out="$("$BEACON" record <"$spoof" 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "B12: a spoofed beacon (altered fields) must be REJECTED at record; got rc=$rc [$out]" + echo "$out" | grep -qi 'spoofed beacon' || fail_msg "B12: the rejection must name the spoof [$out]" + [ ! -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "B12: a rejected spoof must NOT be stored (dead-man clock not advanced)" + # (c) an UNSIGNED beacon is rejected when signing is configured. + unsigned="$H/unsigned.json" + jq -c 'del(.beacon_envelope)' "$SHIPPED" >"$unsigned" + out2="$("$BEACON" record <"$unsigned" 2>&1)"; rc2=$? + [ "$rc2" -ne 0 ] || fail_msg "B12: an unsigned beacon must be REJECTED when signing is configured; got rc=$rc2 [$out2]" + # beacon.sh must not inline the key material. + grep -qF "test-beacon-hmac-key-do-not-echo" "$BEACON" && fail_msg "B12: beacon.sh must NOT inline the HMAC key" + true +) && ok +fi + echo if [ -s "$FAILFILE" ]; then echo "wake beacon harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2 diff --git a/packages/mosaic/framework/tools/wake/test-wake-install.sh b/packages/mosaic/framework/tools/wake/test-wake-install.sh new file mode 100755 index 00000000..839b438b --- /dev/null +++ b/packages/mosaic/framework/tools/wake/test-wake-install.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# test-wake-install.sh — RED-FIRST invariant harness for W7 (EPIC #892, A10): the +# idempotent, fail-closed wake COMPONENT INSTALLER (wake-install.sh) + the W6 +# monitor-integration hardening in beacon.sh (folded-in observation set). +# +# Each group asserts ONE enforcement-path invariant and is designed to go RED if +# that invariant regresses (a targeted mutation to wake-install.sh / beacon.sh +# makes exactly its group fail): +# I1 idempotency — a second component install produces NO diff / no rewrite (i) +# I2 Gate A — a candidate the framework-manifest SSOT does not own is REFUSED, +# fail-closed, with NO partial write (the wake manifest authorizes nothing) (i) +# I3 blank-reset — the reset-line idiom collapses the cadence to EXACTLY ONE +# OnUnitActiveUSec; without it, two values leak (negative control) (iii) +# I4 snapshot-guard — a reap with NO prior snapshot is REFUSED (fail-closed); +# after a snapshot it is allowed (iv) +# I5 fail-closed install-validate — unconfigured HMAC key OR unconfigured/ +# unreachable alarm target FAILS LOUD (non-zero); configured passes; and +# wake-install.sh inlines NO secret/endpoint (v) +# I6 reset->verify->retire lifecycle — overlap leaves the legacy timer running; +# only a §4-vector pass retires it, and only snapshot-guarded (iii) +# I7 ingested_ts staleness — staleness is computed from the monitor's +# receive-time, so a host shipping a FAR-FUTURE emit_ts STILL goes stale (vi-a) +# I8 beacon HMAC-verify at record — a spoofed (bad-sig) beacon is REJECTED (vi-b) +# (I7/I8 exercise the W6 monitor-integration hardening folded into beacon.sh; the +# same invariants are also asserted in depth by test-wake-beacon.sh B11/B12.) +# +# Isolated per-group XDG/systemd/target dirs; no live network, no operator queue, +# no real systemd manager touched (the blank-reset verify uses the deterministic +# merge simulation, which mirrors `systemctl show -p OnUnitActiveUSec`). +# +# SC2030/SC2031 disabled: each group runs in its own ( ) subshell and re-exports +# its env, so environments are isolated by design (same idiom as the W2-W6 harnesses). +# shellcheck disable=SC2030,SC2031 +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WI="$SCRIPT_DIR/wake-install.sh" +BEACON="$SCRIPT_DIR/beacon.sh" +FRAMEWORK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not available" >&2; exit 0; } + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +FAILFILE="$TMP_ROOT/failures" +: >"$FAILFILE" +pass=0 +fail_msg() { echo " FAIL: $*" >&2; echo "x" >>"$FAILFILE"; } +ok() { pass=$((pass + 1)); } + +fresh() { local d="$TMP_ROOT/$1"; rm -rf "$d"; mkdir -p "$d"; printf '%s' "$d"; } + +echo "== I1: idempotency — a second component install produces NO diff ==" +( + TGT="$(fresh i1-target)" + export WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT" + export WAKE_INSTALL_TARGET="$TGT" + out1="$(bash "$WI" install 2>/dev/null)" + w1="$(echo "$out1" | sed -n 's/.*written=\([0-9]*\).*/\1/p')" + [ "${w1:-0}" -gt 0 ] || fail_msg "I1: first install must write the wake component (written=$w1)" + # Snapshot the installed tree, run again, and require byte-identical output. + cp1="$(fresh i1-copy)"; cp -a "$TGT/." "$cp1/" + out2="$(bash "$WI" install 2>/dev/null)" + w2="$(echo "$out2" | sed -n 's/.*written=\([0-9]*\).*/\1/p')" + [ "${w2:-1}" -eq 0 ] || fail_msg "I1: a re-run must write ZERO files (idempotent); got written=$w2" + if ! diff -r "$cp1" "$TGT" >/dev/null 2>&1; then + fail_msg "I1: second install changed the target tree (not idempotent)" + fi +) && ok + +echo "== I2: Gate A — a non-framework-owned candidate is REFUSED (fail-closed, no partial write) ==" +( + TGT="$(fresh i2-target)" + # A manifest that DE-AUTHORIZES the wake tools: tools/wake/** is operator-owned. + # The install MUST refuse the whole thing and write nothing (deny-wins). + BAD_MANIFEST="$TMP_ROOT/i2-manifest.txt" + cat >"$BAD_MANIFEST" <<'EOF' +[framework] +tools/** +systemd/** +defaults/** +[operator] +tools/wake/** +EOF + export WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT" + export WAKE_INSTALL_TARGET="$TGT" + export WAKE_INSTALL_MANIFEST="$BAD_MANIFEST" + out="$(bash "$WI" install 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "I2: install must FAIL when a wake candidate is not framework-owned (got rc=$rc)" + echo "$out" | grep -qi 'Gate A VIOLATION' || fail_msg "I2: refusal must name the Gate A violation [$out]" + # No partial write: the target must have received NO wake tool file. + [ ! -e "$TGT/tools/wake/beacon.sh" ] || fail_msg "I2: a refused install must not have written any wake file (partial write leaked)" + # And the positive control: with the REAL manifest, the same candidates install. + unset WAKE_INSTALL_MANIFEST + bash "$WI" install >/dev/null 2>&1 || fail_msg "I2: install must SUCCEED under the real framework-manifest" + [ -f "$TGT/tools/wake/beacon.sh" ] || fail_msg "I2: real-manifest install must write the wake tools" +) && ok + +echo "== I3: blank-reset — reset idiom yields EXACTLY ONE OnUnitActiveUSec (negative control leaks two) ==" +( + UD="$(fresh i3-units)" + export WAKE_SYSTEMD_USER_DIR="$UD" + UNIT="mosaic-heartbeat@a.timer" + # Base unit already carries a cadence (the legacy value being superseded). + mkdir -p "$UD" + cat >"$UD/$UNIT" <<'EOF' +[Timer] +OnUnitActiveSec=15min +[Install] +WantedBy=timers.target +EOF + # Blank-reset drop-in: empty reset then the new value. + bash "$WI" blank-reset "$UD/$UNIT.d/interval.conf" "30min" >/dev/null 2>&1 \ + || fail_msg "I3: blank-reset drop-in write failed" + out="$(bash "$WI" verify-single "$UNIT" 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "I3: blank-reset must resolve exactly one OnUnitActiveUSec (rc=$rc) [$out]" + echo "$out" | grep -qi 'EXACTLY ONE' || fail_msg "I3: verify must confirm exactly-one [$out]" + # NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> TWO values -> fail. + cat >"$UD/$UNIT.d/interval.conf" <<'EOF' +[Timer] +OnUnitActiveSec=30min +EOF + out2="$(bash "$WI" verify-single "$UNIT" 2>&1)"; rc2=$? + [ "$rc2" -ne 0 ] || fail_msg "I3: without the reset line two cadences must leak (verify should FAIL) [$out2]" +) && ok + +echo "== I4: snapshot-guard — reap without a snapshot is REFUSED; allowed after snapshot ==" +( + UD="$(fresh i4-units)"; SNAP="$(fresh i4-snap)" + export WAKE_SYSTEMD_USER_DIR="$UD" + export WAKE_SNAPSHOT_DIR="$SNAP" + UNIT="mosaic-heartbeat@b.timer" + printf '[Timer]\nOnUnitActiveSec=10min\n' >"$UD/$UNIT" + # (a) reap WITHOUT snapshot -> refuse, unit still present. + out="$(bash "$WI" reap-unit "$UNIT" 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "I4: reap without a snapshot must be REFUSED (rc=$rc)" + echo "$out" | grep -qi 'snapshot-guard' || fail_msg "I4: refusal must name the snapshot-guard [$out]" + [ -f "$UD/$UNIT" ] || fail_msg "I4: a refused reap must NOT delete the unit" + # (b) snapshot, then reap -> allowed, unit gone, snapshot retained. + bash "$WI" snapshot-unit "$UNIT" >/dev/null 2>&1 || fail_msg "I4: snapshot-unit failed" + bash "$WI" reap-unit "$UNIT" >/dev/null 2>&1 || fail_msg "I4: reap after snapshot must succeed" + [ ! -f "$UD/$UNIT" ] || fail_msg "I4: reap after snapshot must remove the unit" + [ -f "$SNAP/$UNIT" ] || fail_msg "I4: the snapshot must be retained after reap" +) && ok + +echo "== I5: fail-closed install-validate — unconfigured HMAC/alarm FAIL LOUD; no secret inlined ==" +( + H="$(fresh i5)" + CRED="$H/credentials.json" + export MOSAIC_CREDENTIALS_FILE="$CRED" + SECRET_KEY="s3cr3t-HMAC-DO-NOT-ECHO-xyz" + SECRET_ENDPOINT="https://monitor.invalid/alarm/DO-NOT-INLINE-abc123" + # --- HMAC key: absent store -> fail loud. + export WAKE_HMAC_KEY_NAME="primary" + out="$(bash "$WI" validate-hmac-key 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "I5: missing credential store must FAIL LOUD for the HMAC key (rc=$rc)" + echo "$out" | grep -qi 'UNSIGNED' || fail_msg "I5: HMAC failure must warn about unsigned wakes [$out]" + # Now provide the key by-name -> pass, and the value must NEVER be echoed. + jq -cn --arg k "$SECRET_KEY" '{wake:{hmac_keys:{"primary":$k}}}' >"$CRED" + out2="$(bash "$WI" validate-hmac-key 2>&1)"; rc2=$? + [ "$rc2" -eq 0 ] || fail_msg "I5: a by-name-resolvable HMAC key must pass (rc=$rc2) [$out2]" + echo "$out2" | grep -qF "$SECRET_KEY" && fail_msg "I5: validate must NEVER echo the HMAC key material" + # --- alarm target: unconfigured -> fail loud (silent no-alarm host). + unset WAKE_ALARM_SINK_CMD + out3="$(bash "$WI" validate-alarm-target 2>&1)"; rc3=$? + [ "$rc3" -ne 0 ] || fail_msg "I5: unconfigured alarm target must FAIL LOUD (rc=$rc3)" + echo "$out3" | grep -qi 'silent no-alarm host' || fail_msg "I5: alarm failure must name the silent-no-alarm hazard [$out3]" + # unreachable -> fail loud. + export WAKE_ALARM_SINK_CMD="false" + out4="$(bash "$WI" validate-alarm-target 2>&1)"; rc4=$? + [ "$rc4" -ne 0 ] || fail_msg "I5: unreachable alarm sink must FAIL LOUD (rc=$rc4)" + echo "$out4" | grep -qi 'UNREACHABLE' || fail_msg "I5: alarm failure must name the unreachable target [$out4]" + # reachable -> pass. + export WAKE_ALARM_SINK_CMD="cat >/dev/null" + bash "$WI" validate-alarm-target >/dev/null 2>&1 || fail_msg "I5: a configured+reachable alarm sink must pass" + # The installer file must inline NO secret/endpoint. + grep -qF "$SECRET_ENDPOINT" "$WI" && fail_msg "I5: wake-install.sh must NOT inline any alarm endpoint" + grep -qE 'hmac_keys[^A-Za-z_].*=[^=].*[A-Za-z0-9]{8,}' "$WI" && fail_msg "I5: wake-install.sh must NOT inline key material" + true +) && ok + +echo "== I6: reset->verify->retire — overlap keeps legacy running; only §4-vector pass retires ==" +( + UD="$(fresh i6-units)"; SNAP="$(fresh i6-snap)" + export WAKE_SYSTEMD_USER_DIR="$UD" + export WAKE_SNAPSHOT_DIR="$SNAP" + TIMER="mosaic-heartbeat@c.timer" + printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER" + # (a) overlap phase (NO --vector-passed): reset+verify, legacy LEFT RUNNING. + out="$(bash "$WI" reset-verify-retire c --interval 30min 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "I6: overlap reset-verify must succeed (rc=$rc) [$out]" + [ -f "$UD/$TIMER" ] || fail_msg "I6: overlap phase must LEAVE the legacy timer running (retire withheld)" + [ -f "$SNAP/$TIMER" ] || fail_msg "I6: the legacy timer must be snapshotted before any retire" + echo "$out" | grep -qi 'LEFT RUNNING' || fail_msg "I6: overlap must announce retire is withheld [$out]" + # (b) §4-vector pass: retire LAST, snapshot-guarded. + out2="$(bash "$WI" reset-verify-retire c --interval 30min --vector-passed 2>&1)"; rc2=$? + [ "$rc2" -eq 0 ] || fail_msg "I6: vector-passed retire must succeed (rc=$rc2) [$out2]" + [ ! -f "$UD/$TIMER" ] || fail_msg "I6: on §4-vector pass the legacy timer must be RETIRED" + [ -f "$SNAP/$TIMER" ] || fail_msg "I6: the snapshot must survive the retire" +) && ok + +# ── W6 monitor-integration hardening folded into beacon.sh (vi) ──────────────── + +echo "== I7: ingested_ts staleness — a far-future emit_ts still goes stale (receive-time governs) ==" +( + H="$(fresh i7)" + export WAKE_BEACON_RECEIVED="$H/received.json" + export WAKE_ALARM_SINK_CMD="cat >$H/alarm.json" + now="$(date +%s)" + # Host lies with a far-future emit_ts; record stamps the monitor's ingested_ts. + jq -cn --argjson ts "$((now + 100000))" \ + '{kind:"wake-beacon", beacon_seq:3, emit_ts:$ts, host_id:"h", independence:"off-host", degraded:false}' \ + | bash "$BEACON" record + # Simulate the beacon having been received 100s ago (backdate ingested_ts only). + jq -c --argjson ing "$((now - 100))" '.ingested_ts = $ing' "$WAKE_BEACON_RECEIVED" >"$H/t" && mv "$H/t" "$WAKE_BEACON_RECEIVED" + out="$(bash "$BEACON" check --slo-seconds 5 2>&1)"; rc=$? + [ "$rc" -eq 1 ] || fail_msg "I7: far-future emit_ts must NOT defer staleness — receive-time governs (expected exit 1); got $rc [$out]" +) && ok + +echo "== I8: beacon HMAC-verify at record — a spoofed (bad-sig) beacon is REJECTED ==" +if ! command -v openssl >/dev/null 2>&1; then + echo "SKIP: openssl not available" >&2 +else +( + H="$(fresh i8)" + export WAKE_STATE_HOME="$H" + export MOSAIC_CREDENTIALS_FILE="$H/credentials.json" + export WAKE_BEACON_HMAC_KEY_NAME="beacon" + jq -cn --arg k "i8-hmac-key" '{wake:{hmac_keys:{"beacon":$k}}}' >"$MOSAIC_CREDENTIALS_FILE" + export WAKE_BEACON_INDEPENDENCE="off-host" + export WAKE_BEACON_SINK_CMD="cat >$H/shipped.json" + export WAKE_BEACON_RECEIVED="$H/received.json" + bash "$BEACON" emit >/dev/null 2>&1 || fail_msg "I8: signed emit must succeed" + bash "$BEACON" record <"$H/shipped.json" >/dev/null 2>&1 || fail_msg "I8: an authentic signed beacon must be accepted" + rm -f "$WAKE_BEACON_RECEIVED" + jq -c '.beacon_seq = 88888 | .host_id = "attacker"' "$H/shipped.json" >"$H/spoof.json" + out="$(bash "$BEACON" record <"$H/spoof.json" 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "I8: a spoofed beacon must be REJECTED at record (rc=$rc) [$out]" + [ ! -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "I8: a rejected spoof must NOT advance the dead-man clock" +) && ok +fi + +echo +if [ -s "$FAILFILE" ]; then + echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2 + exit 1 +fi +echo "wake install harness: all invariants passed ($pass groups)" diff --git a/packages/mosaic/framework/tools/wake/wake-install.sh b/packages/mosaic/framework/tools/wake/wake-install.sh new file mode 100755 index 00000000..948d0666 --- /dev/null +++ b/packages/mosaic/framework/tools/wake/wake-install.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +# wake-install.sh — A10 of the wake canon (EPIC #892, W7): the idempotent, +# fail-closed COMPONENT INSTALLER for the wake component. +# +# This is the ENFORCEMENT-PATH installer (#869 publish-gate discipline): every +# path is fail-closed, idempotent, and never silently falls through. It is driven +# by `framework/install.sh --component wake`, and every subcommand is also invoked +# directly by the red-first harness (test-wake-install.sh). +# +# CONTRACT ANCHORS (PACKAGING-PLAN §3/§5 + CONVERGED-DESIGN): +# (i) Idempotent component install + Gate A. The wake component ships its own +# manifest.txt as VERSION METADATA ONLY. Path-ownership stays the SINGLE +# SSOT framework-manifest.txt (consumed by BOTH bash + TS). The component +# file set is INTERSECTED-AND-VALIDATED against framework-manifest +# ownership: the wake manifest MUST NOT independently authorize any +# write/prune outside framework-manifest ownership. Re-running is a no-op. +# (iii) Blank-reset idiom on the LEGACY mosaic-heartbeat@.timer cadence +# drop-in during the §5 overlap->retire lifecycle: SNAPSHOT the legacy +# unit; write any per-class fallback-cadence drop-in in the blank-reset +# form (an empty OnUnitActiveSec= reset line before the new value, so +# exactly ONE OnUnitActiveUSec results); on §4-vector pass, REMOVE the +# legacy mosaic-heartbeat@ units (retire-LAST). Post-apply verify = exactly +# one OnUnitActiveUSec. +# (iv) snapshot-guard: block any reap / clean-checkout of a deployed unit +# WITHOUT a snapshot first (the deployed-from-uncommitted failure class +# must not recur). Fail-closed: no snapshot => refuse to reap. +# (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a): the +# operator's W6 alarm-sink target (resolved BY NAME via load_credentials) +# must be CONFIGURED + reachable at install; the W3/W7 HMAC key must +# resolve BY NAME. Missing/unreachable => FAIL LOUD. This installer ships +# NO endpoint value and NO secret, and echoes neither. +# +# Operator-agnostic (framework firewall): no operator paths/names/secrets/hosts. +# All state via XDG/env; the HMAC key + alarm endpoint are resolved BY NAME. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ─── paths (all overridable so the harness can isolate every run) ──────────── +# Source framework tree (the checkout being installed FROM). +WAKE_INSTALL_SOURCE="${WAKE_INSTALL_SOURCE:-$(cd "$SCRIPT_DIR/../.." && pwd)}" +# Target mosaic home (installed INTO). +WAKE_INSTALL_TARGET="${WAKE_INSTALL_TARGET:-${MOSAIC_HOME:-$HOME/.config/mosaic}}" +# framework-manifest.txt SSOT (Gate A). Override lets the harness prove a +# de-authorizing manifest makes the install REFUSE (no write outside ownership). +WAKE_INSTALL_MANIFEST="${WAKE_INSTALL_MANIFEST:-$WAKE_INSTALL_SOURCE/framework-manifest.txt}" +# systemd user unit dir the legacy timer / new units are deployed under. +WAKE_SYSTEMD_USER_DIR="${WAKE_SYSTEMD_USER_DIR:-$HOME/.config/systemd/user}" +# Retained snapshot store for the snapshot-guard (iv) — outside any repo. +WAKE_SNAPSHOT_DIR="${WAKE_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake/unit-snapshots}" + +# shellcheck source=../_lib/manifest.sh disable=SC1091 +. "$SCRIPT_DIR/../_lib/manifest.sh" + +if [[ -t 2 ]]; then + _C_G='\033[0;32m' _C_Y='\033[0;33m' _C_R='\033[0;31m' _C_0='\033[0m' +else + _C_G='' _C_Y='' _C_R='' _C_0='' +fi +wi_ok() { printf " ${_C_G}OK${_C_0} %s\n" "$1" >&2; } +wi_warn() { printf " ${_C_Y}WARN${_C_0} %s\n" "$1" >&2; } +wi_fail() { printf " ${_C_R}FAIL${_C_0} %s\n" "$1" >&2; } + +# ═══════════════════════════════════════════════════════════════════════════════ +# (i) Idempotent component-manifest install + Gate A +# ═══════════════════════════════════════════════════════════════════════════════ + +# The wake component's candidate file set, as source-relative paths. It is DERIVED +# (from the shipped filesystem under tools/wake/ + the two cross-subtree files the +# component owns) — the wake VERSION manifest authorizes NOTHING here. Every +# candidate is intersected-and-validated against framework-manifest ownership in +# wi_install before a single byte is written (Gate A). +_wi_component_candidates() { + local src="$WAKE_INSTALL_SOURCE" abs + if [[ -d "$src/tools/wake" ]]; then + while IFS= read -r -d '' abs; do + printf '%s\n' "${abs#"$src"/}" + done < <(find "$src/tools/wake" -type f -print0 | sort -z) + fi + # Cross-subtree files that logically belong to the wake component but live in + # shared framework subtrees. Listed here for ENUMERATION only — ownership is + # still decided solely by framework-manifest.txt in wi_install. + printf '%s\n' "systemd/user/mosaic-wake.service" + printf '%s\n' "defaults/wake-watch-list.schema.json" +} + +# wi_install — idempotent component install. Gate A: refuse to write any candidate +# the framework-manifest SSOT does not own. Idempotent: an unchanged file is +# skipped (no rewrite, no mtime churn), so a second run produces no diff. +wi_install() { + local src="$WAKE_INSTALL_SOURCE" dst="$WAKE_INSTALL_TARGET" + # Load + validate the SSOT ownership manifest (fail-closed on missing/empty). + manifest_load "$WAKE_INSTALL_MANIFEST" || { + wi_fail "framework-manifest.txt failed to load ($WAKE_INSTALL_MANIFEST) — refusing to install (fail-closed)." + return 1 + } + + local rel written=0 skipped=0 missing=0 + # PASS 1 — Gate A validation FIRST, before any write. If ANY candidate resolves + # outside framework-manifest ownership, refuse the WHOLE install (no partial + # write): the wake component must never author a write the SSOT does not own. + while IFS= read -r rel; do + [[ -n "$rel" ]] || continue + if ! manifest_is_framework "$rel"; then + wi_fail "Gate A VIOLATION — wake candidate '$rel' is NOT framework-owned per framework-manifest.txt. The wake component manifest must not authorize a write outside framework-manifest ownership. Refusing the entire component install (fail-closed)." + return 1 + fi + done < <(_wi_component_candidates) + + # PASS 2 — copy. Every path is already proven framework-owned above. + while IFS= read -r rel; do + [[ -n "$rel" ]] || continue + if [[ ! -f "$src/$rel" ]]; then + # A cross-subtree candidate that isn't shipped in this source is not an + # error (it may not exist yet); note and continue. tools/wake/** entries + # always exist because they were enumerated from the filesystem. + missing=$((missing + 1)) + continue + fi + if [[ -f "$dst/$rel" ]] && cmp -s "$src/$rel" "$dst/$rel"; then + skipped=$((skipped + 1)) + continue + fi + [[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}" + cp "$src/$rel" "$dst/$rel" + case "$rel" in *.sh) chmod +x "$dst/$rel" 2>/dev/null || true ;; esac + written=$((written + 1)) + done < <(_wi_component_candidates) + + wi_ok "wake component install: $written written, $skipped unchanged, $missing not-shipped (Gate A: all writes framework-owned)." + # Machine-readable summary for the harness (idempotency assertion keys off it). + printf 'wake-install: written=%s skipped=%s missing=%s\n' "$written" "$skipped" "$missing" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Resolve the credential store the same way load_credentials / sign.sh do, WITHOUT +# ever echoing a resolved value. Sourcing credentials.sh only sets +# MOSAIC_CREDENTIALS_FILE; we never call load_credentials for a secret here. +_wi_cred_file() { + local cred_lib="$SCRIPT_DIR/../_lib/credentials.sh" + if [[ -f "$cred_lib" ]]; then + # shellcheck source=../_lib/credentials.sh disable=SC1091 + . "$cred_lib" + fi + printf '%s' "${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}" +} + +# wi_validate_hmac_key — the W3/W7 HMAC key MUST resolve BY NAME +# (.wake.hmac_keys.) in the operator credential store. Missing => FAIL LOUD: +# the installer must never deploy a config that would emit UNSIGNED wakes. Only a +# boolean/verdict is printed — the key material is NEVER echoed. +wi_validate_hmac_key() { + command -v jq >/dev/null 2>&1 || { wi_fail "jq is required for install-validate."; return 3; } + local name="${WAKE_HMAC_KEY_NAME:-default}" cred_file present + cred_file="$(_wi_cred_file)" + if [[ ! -f "$cred_file" ]]; then + wi_fail "HMAC-key validate — credential store not found ($cred_file): cannot resolve key name '$name'. Refusing to deploy a config that would emit UNSIGNED wakes (fail-closed)." + return 1 + fi + # jq -e sets exit status; we capture ONLY presence, never the value. + if present="$(jq -re --arg n "$name" '(.wake.hmac_keys[$n] // "") | if . == "" then empty else "present" end' "$cred_file" 2>/dev/null)" && [[ "$present" == present ]]; then + wi_ok "HMAC key '$name' resolves by-name in the credential store (value NOT read/echoed)." + return 0 + fi + wi_fail "HMAC-key validate — key name '$name' is NOT configured in the credential store (.wake.hmac_keys). A missing key would emit UNSIGNED wakes. FAIL LOUD (fail-closed)." + return 1 +} + +# wi_validate_alarm_target — the operator's W6 alarm-sink target must be +# CONFIGURED (WAKE_ALARM_SINK_CMD set) and REACHABLE (a probe payload through the +# pluggable adapter exits 0). The adapter resolves its endpoint BY NAME internally +# (load_credentials); this installer ships/writes NO endpoint value. An +# unconfigured OR unreachable target FAILS LOUD — no silent no-alarm host (G1/G2a). +wi_validate_alarm_target() { + if [[ -z "${WAKE_ALARM_SINK_CMD:-}" ]]; then + wi_fail "alarm-target validate — WAKE_ALARM_SINK_CMD is UNSET: no off-host alarm route is configured. A host with no alarm sink is a silent no-alarm host. FAIL LOUD (G1/G2a, fail-closed)." + return 1 + fi + # Reachability probe: send a benign install-probe payload through the adapter. + # A non-zero exit = UNREACHABLE target => fail loud. Adapter output is + # discarded so no resolved endpoint value can leak into installer output. + local probe='{"kind":"wake-install-probe"}' + if ! printf '%s\n' "$probe" | sh -c "$WAKE_ALARM_SINK_CMD" >/dev/null 2>&1; then + wi_fail "alarm-target validate — the alarm sink is UNREACHABLE (WAKE_ALARM_SINK_CMD probe exited non-zero): the alarm cannot route to a human/other-host. FAIL LOUD (G1/G2a, fail-closed)." + return 1 + fi + wi_ok "alarm-sink target is configured + reachable (endpoint resolved by-name by the adapter; NOT read/echoed here)." + return 0 +} + +# wi_validate_targets — both gates. Either failure fails the whole validate loud. +wi_validate_targets() { + local rc=0 + wi_validate_hmac_key || rc=1 + wi_validate_alarm_target || rc=1 + if [[ "$rc" -ne 0 ]]; then + wi_fail "install-validate FAILED — refusing to enable the wake service with an unconfigured signing key or a silent no-alarm host (fail-closed)." + return 1 + fi + wi_ok "install-validate PASSED — HMAC key + alarm sink are both fail-closed-ready." + return 0 +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# (iv) snapshot-guard — never reap a deployed unit without a snapshot first +# ═══════════════════════════════════════════════════════════════════════════════ + +_wi_snapshot_path() { printf '%s/%s' "$WAKE_SNAPSHOT_DIR" "$1"; } + +# wi_snapshot_unit UNIT — retain a byte copy (+ the whole .d drop-in tree) of a +# deployed unit BEFORE any reap/clean-checkout. Idempotent (re-snapshotting an +# unchanged unit just refreshes it). Private (0700/0600): a unit can reference +# operator paths. +wi_snapshot_unit() { + local unit="$1" + [[ -n "$unit" ]] || { wi_fail "snapshot-unit: UNIT name required."; return 2; } + local src="$WAKE_SYSTEMD_USER_DIR/$unit" snap + snap="$(_wi_snapshot_path "$unit")" + if [[ ! -e "$src" ]]; then + wi_fail "snapshot-unit — deployed unit '$unit' not found under $WAKE_SYSTEMD_USER_DIR; nothing to snapshot." + return 1 + fi + local old_umask; old_umask="$(umask)"; umask 077 + mkdir -p "$(dirname "$snap")" + cp "$src" "$snap" + # Capture the drop-in dir too (cadence drop-ins live in .d/). + if [[ -d "$WAKE_SYSTEMD_USER_DIR/$unit.d" ]]; then + rm -rf "$snap.d"; mkdir -p "$snap.d" + cp -a "$WAKE_SYSTEMD_USER_DIR/$unit.d/." "$snap.d/" + fi + umask "$old_umask" + wi_ok "snapshot-unit — '$unit' snapshotted to $snap (reap is now unblocked for this unit)." + return 0 +} + +# wi_has_snapshot UNIT — rc 0 iff a snapshot for UNIT exists. +wi_has_snapshot() { + [[ -f "$(_wi_snapshot_path "$1")" ]] +} + +# wi_reap_unit UNIT — remove a deployed unit (+ its .d drop-ins). FAIL-CLOSED: a +# reap with NO prior snapshot is REFUSED. This is the guard against the +# deployed-from-uncommitted failure class recurring: you cannot clean-checkout / +# reap a deployed-but-uncommitted unit until it is snapshotted. +wi_reap_unit() { + local unit="$1" + [[ -n "$unit" ]] || { wi_fail "reap-unit: UNIT name required."; return 2; } + if ! wi_has_snapshot "$unit"; then + wi_fail "snapshot-guard — REFUSING to reap '$unit': no snapshot exists (a reap/clean-checkout of a deployed-but-uncommitted unit without a snapshot is the exact failure class this guard forbids). Run 'wake-install.sh snapshot-unit $unit' first." + return 1 + fi + rm -f "$WAKE_SYSTEMD_USER_DIR/$unit" + rm -rf "$WAKE_SYSTEMD_USER_DIR/$unit.d" + wi_ok "reap-unit — '$unit' reaped (snapshot retained at $(_wi_snapshot_path "$unit"))." + return 0 +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# (iii) blank-reset idiom + exactly-one-OnUnitActiveUSec verify + retire +# ═══════════════════════════════════════════════════════════════════════════════ + +# wi_write_blank_reset_dropin DROPIN_FILE INTERVAL — write a [Timer] cadence +# drop-in in the BLANK-RESET form: an empty OnUnitActiveSec= reset line CLEARS any +# previously accumulated (list-valued) cadence, then the new value sets exactly +# one. This is byte-identical to the installer-7 idiom and guarantees exactly ONE +# effective OnUnitActiveUSec after systemd merges the base unit + all drop-ins. +wi_write_blank_reset_dropin() { + local file="$1" interval="$2" + [[ -n "$file" && -n "$interval" ]] || { wi_fail "blank-reset: DROPIN_FILE and INTERVAL required."; return 2; } + mkdir -p "$(dirname "$file")" + # The empty reset line MUST precede the new value (order is load-bearing). + cat >"$file" < $interval)." + return 0 +} + +# _wi_effective_onunitactive UNIT_FILE DROPIN_DIR — echo, one per line, the +# EFFECTIVE OnUnitActiveSec values after simulating systemd's list-merge: process +# the base unit then every drop-in in the .d dir in lexical (systemd) order; an +# EMPTY assignment RESETS the accumulated list, a non-empty one APPENDS. Mirrors +# `systemctl show -p OnUnitActiveUSec` semantics without needing a live manager. +_wi_effective_onunitactive() { + local unit_file="$1" dropin_dir="$2" + local -a eff=() + _wi_merge_file() { + local f="$1" line val + [[ -f "$f" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + case "$line" in + OnUnitActiveSec=*) + val="${line#OnUnitActiveSec=}" + val="${val#"${val%%[![:space:]]*}"}" + val="${val%"${val##*[![:space:]]}"}" + if [[ -z "$val" ]]; then + eff=() # empty assignment RESETS the list + else + eff+=("$val") # non-empty APPENDS + fi + ;; + esac + done <"$f" + } + _wi_merge_file "$unit_file" + if [[ -d "$dropin_dir" ]]; then + local d + while IFS= read -r d; do + [[ -n "$d" ]] && _wi_merge_file "$d" + done < <(find "$dropin_dir" -maxdepth 1 -type f -name '*.conf' | LC_ALL=C sort) + fi + local v + for v in ${eff[@]+"${eff[@]}"}; do printf '%s\n' "$v"; done +} + +# wi_verify_single_active UNIT — post-apply verify: exactly ONE effective +# OnUnitActiveUSec. Prefers a live `systemctl --user show` when available + +# loaded; otherwise falls back to the deterministic merge simulation above. +# rc 0 = exactly one; rc 1 = zero or more-than-one (fail loud). +wi_verify_single_active() { + local unit="$1" + [[ -n "$unit" ]] || { wi_fail "verify-single: UNIT name required."; return 2; } + local count="" + if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then + # Live path: systemd already merged base+drop-ins. One line, one value. + local shown + if shown="$(systemctl --user show "$unit" -p OnUnitActiveUSec --value 2>/dev/null)"; then + # --value prints the merged value (may be a space-joined list if >1). + # shellcheck disable=SC2086 + set -- $shown + count=$# + fi + fi + if [[ -z "$count" ]]; then + count="$(_wi_effective_onunitactive "$WAKE_SYSTEMD_USER_DIR/$unit" "$WAKE_SYSTEMD_USER_DIR/$unit.d" | grep -c .)" + fi + if [[ "$count" -eq 1 ]]; then + wi_ok "verify-single — '$unit' resolves EXACTLY ONE OnUnitActiveUSec (blank-reset idiom held)." + return 0 + fi + wi_fail "verify-single — '$unit' resolves $count OnUnitActiveUSec values (expected exactly 1). The blank-reset idiom did not collapse the cadence. FAIL LOUD." + return 1 +} + +# wi_reset_verify_retire AGENT [--interval V] [--vector-passed] — the §5 +# reset->verify->retire lifecycle for the legacy mosaic-heartbeat@ timer. +# This is the TESTABLE acceptance path: +# 1. SNAPSHOT the legacy timer (snapshot-guard precondition for any later reap). +# 2. IF --interval is given, write the fallback-cadence drop-in in blank-reset +# form, then VERIFY exactly-one-OnUnitActiveUSec. +# 3. ONLY on --vector-passed (the §4-vector pass) REMOVE the legacy units, and +# only via the snapshot-guarded reap (retire-LAST). +wi_reset_verify_retire() { + local agent="" interval="" vector_passed=0 + agent="${1:-}"; shift || true + [[ -n "$agent" ]] || { wi_fail "reset-verify-retire: AGENT required."; return 2; } + while [[ $# -gt 0 ]]; do + case "$1" in + --interval) interval="${2:-}"; shift 2 ;; + --vector-passed) vector_passed=1; shift ;; + *) wi_fail "reset-verify-retire: unknown option '$1'."; return 2 ;; + esac + done + local legacy_timer="mosaic-heartbeat@$agent.timer" + local legacy_service="mosaic-heartbeat@$agent.service" + + # 1) SNAPSHOT FIRST — unconditionally, before touching or retiring anything. + wi_snapshot_unit "$legacy_timer" || { + wi_fail "reset-verify-retire — could not snapshot the legacy timer '$legacy_timer'; refusing to proceed (snapshot-guard, fail-closed)." + return 1 + } + + # 2) blank-reset the fallback cadence (if a per-class fallback timer is used), + # then verify exactly one effective OnUnitActiveUSec. + if [[ -n "$interval" ]]; then + wi_write_blank_reset_dropin "$WAKE_SYSTEMD_USER_DIR/$legacy_timer.d/interval.conf" "$interval" || return 1 + wi_verify_single_active "$legacy_timer" || { + wi_fail "reset-verify-retire — blank-reset verify failed for '$legacy_timer'; refusing to retire on an unverified cadence (fail-closed)." + return 1 + } + fi + + # 3) RETIRE LAST — only on the §4-vector pass, and only via the snapshot-guarded + # reap. Without --vector-passed the legacy units are LEFT RUNNING (overlap). + if [[ "$vector_passed" -eq 1 ]]; then + wi_reap_unit "$legacy_timer" || return 1 + # The paired service may not be independently deployed; reap it best-effort + # but still snapshot-guarded if present. + if [[ -e "$WAKE_SYSTEMD_USER_DIR/$legacy_service" ]]; then + wi_snapshot_unit "$legacy_service" && wi_reap_unit "$legacy_service" || return 1 + fi + wi_ok "reset-verify-retire — legacy '$agent' heartbeat units RETIRED (post §4-vector pass, snapshot-guarded)." + else + wi_ok "reset-verify-retire — overlap phase: cadence reset+verified, legacy '$agent' units LEFT RUNNING (retire withheld until §4-vector pass)." + fi + return 0 +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# CLI dispatch — only when executed directly, never when sourced. +# ═══════════════════════════════════════════════════════════════════════════════ +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + cmd="${1:-}"; shift || true + case "$cmd" in + install) wi_install "$@" ;; + validate-targets) wi_validate_targets "$@" ;; + validate-hmac-key) wi_validate_hmac_key "$@" ;; + validate-alarm-target) wi_validate_alarm_target "$@" ;; + snapshot-unit) wi_snapshot_unit "$@" ;; + reap-unit) wi_reap_unit "$@" ;; + blank-reset) wi_write_blank_reset_dropin "$@" ;; + verify-single) wi_verify_single_active "$@" ;; + reset-verify-retire) wi_reset_verify_retire "$@" ;; + -h | --help | help | '') + cat >&2 <<'EOF' +Usage: wake-install.sh [args] + +Commands: + install Idempotent component-manifest install + Gate A. + validate-targets Fail-closed HMAC-key + alarm-target validate (v). + validate-hmac-key HMAC key resolves by-name, else fail loud. + validate-alarm-target Alarm sink configured + reachable, else fail loud. + snapshot-unit UNIT Snapshot a deployed unit (snapshot-guard precondition). + reap-unit UNIT Reap a deployed unit; REFUSES without a snapshot. + blank-reset DROPIN_FILE INTERVAL Write a blank-reset cadence drop-in. + verify-single UNIT Verify exactly one effective OnUnitActiveUSec. + reset-verify-retire AGENT [--interval V] [--vector-passed] + The §5 reset->verify->retire lifecycle. +EOF + [[ "$cmd" == -h || "$cmd" == --help || "$cmd" == help ]] && exit 0 + exit 2 + ;; + *) + wi_fail "unknown command '$cmd'" + exit 2 + ;; + esac +fi diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 8dc6a4b1..7d8e6181 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh" + "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-install.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*", diff --git a/packages/mosaic/src/framework/manifest-parity.spec.ts b/packages/mosaic/src/framework/manifest-parity.spec.ts index cd06ac2d..5cf910e8 100644 --- a/packages/mosaic/src/framework/manifest-parity.spec.ts +++ b/packages/mosaic/src/framework/manifest-parity.spec.ts @@ -81,6 +81,17 @@ const PROBE_PATHS = [ 'skills/mosaic-context-refresh/SKILL.md', 'tools/git/pr-create.sh', 'tools/_lib/manifest.sh', + // #892 W7 (Gate A): every wake component candidate the component installer may + // write MUST resolve framework-owned on BOTH resolvers. If either drifted to + // operator, the component install would refuse a legitimate write (or, worse, + // a divergent resolver could authorize one the SSOT forbids). These prove the + // wake component authorizes nothing outside framework-manifest ownership. + 'tools/wake/beacon.sh', + 'tools/wake/wake-install.sh', + 'tools/wake/manifest.txt', + 'tools/wake/test-wake-install.sh', + 'systemd/user/mosaic-wake.service', + 'defaults/wake-watch-list.schema.json', 'defaults/SOUL.md', 'fleet/README.md', 'fleet/roles/coder.md',