diff --git a/packages/mosaic/framework/tools/wake/detector.sh b/packages/mosaic/framework/tools/wake/detector.sh index d0c33175..2c064eaa 100755 --- a/packages/mosaic/framework/tools/wake/detector.sh +++ b/packages/mosaic/framework/tools/wake/detector.sh @@ -414,6 +414,22 @@ cmd_poll_once() { _wake_clean_stale_tmp "$STATE_DIR" mkdir -p "$DET_DIR" + local failed=0 + + # #958 preimage provenance: check the OPERATOR-SIDE preimage definition (the + # source adapter file, the watch-list, operator-declared extras) BEFORE + # observing any source. A changed preimage re-baselines EVERY source at once; + # running the check first means its first-class cause line is enqueued at a + # LOWER observed_seq than the N per-source deltas it explains, so the digest + # shows the cause, not just the flood. An infrastructure failure of the check + # is LOUD and marks this pass failed (G2a discipline — never read as "no + # change"), but source observation still proceeds: provenance must not be + # able to starve wake delivery. + if ! "$SCRIPT_DIR/preimage.sh" check --enqueue; then + echo "detector.sh: FAIL LOUD — preimage provenance check failed (see preimage.sh above); source observation continues but this pass exits non-zero." >&2 + failed=1 + fi + # Iterate the DECLARED source-coverage inventory (§4/G3): only sources listed # in watches[].sources[] are polled. An omitted source is not observed (and so # cannot make anything pass vacuously); a referenced-but-undefined source is a @@ -427,7 +443,7 @@ cmd_poll_once() { exit 2 fi - local failed=0 kind id def class + local kind id def class while IFS=$'\t' read -r kind id; do [ -n "$kind" ] || continue # Resolve the source definition from its top-level collection by id. diff --git a/packages/mosaic/framework/tools/wake/manifest.txt b/packages/mosaic/framework/tools/wake/manifest.txt index ff43f841..d0342b1b 100644 --- a/packages/mosaic/framework/tools/wake/manifest.txt +++ b/packages/mosaic/framework/tools/wake/manifest.txt @@ -374,8 +374,80 @@ # Changed: store.sh, digest.sh, ack.sh # (+ test-wake-store-ack.sh T13-T16, # test-wake-digest-quarantine.sh Q12-Q16). +# 0.7.0 #958 A11 preimage.sh — durable provenance for the OPERATOR-SIDE +# preimage definition (wake-pilot finding: source-adapter.sh was +# unversioned, so the operator-owned bytes every observed_hash is +# computed FROM had thinner provenance than any hash they feed; +# attribution required an agent transcript). NEW tool + two +# pre-step integrations, ADDITIVE: (1) preimage.sh derives the +# preimage set from the RUNTIME env (the resolved +# WAKE_DETECTOR_SOURCE_CMD file, WAKE_WATCH_LIST, optional +# WAKE_PREIMAGE_EXTRA paths) and, per file, records +# {ts,path,sha256,size,mtime,prev} to an append-only ledger + +# captures the bytes CONTENT-ADDRESSED under +# $STATE_DIR/preimage/objects/ — prior bytes + change +# time are answerable from durable state alone, no transcript +# (acceptance a). A git-repo-in-operator-dir design was REJECTED: +# its who/why claim is false under shared-author fleets, and +# .gitignore is pattern-based where acceptance (b) demands +# fail-closed. (2) CREDENTIAL HARD GATE (acceptance b), FOUR +# LAYERS (#964 re-verdict: B2 cases C+D): (i) POLARITY — extras +# (WAKE_PREIMAGE_EXTRA) are RECORD-ONLY by default (ledger row + +# cause line, NO bytes); byte capture for an extra requires the +# path listed in WAKE_PREIMAGE_CAPTURE (colon-separated opt-in); +# only the core set (adapter, watch-list) is capture-eligible by +# default — a shape list can only refuse the secrets someone +# already enumerated, so safety may not rest on one (#964-D). +# (ii) PATH DENY evaluated on BOTH the raw and realpath-resolved +# candidate forms against BOTH unresolved and resolved +# mosaic-home anchors (credentials.json, +# tools/_lib/credentials.json, credentials/**, any basename +# credentials.json) — a deny list written in unresolved paths +# cannot match a path resolved before it arrived (#964-C), and a +# symlinked opt-in entry cannot smuggle a denied target (deny +# runs FIRST, independent of what the opt-in matched). (iii) +# CONTENT DENY on the opt-in path only, defense-in-depth NOT the +# safety mechanism: digest.sh's six scrub shapes + a +# named-assignment probe (key/token/secret/passw/hmac/credential/ +# bearer = unbroken value >=16 chars); probe ERROR fails TOWARD +# refusal (an error exit is not a negative result); false +# positives are acceptable — a false match only withholds byte +# capture, never tracking. (iv) size cap WAKE_PREIMAGE_MAX_BYTES +# (default 1 MiB). Deny ALWAYS wins over the opt-in. A refused +# file still gets its hash/size/mtime row (captured:false + +# refused reason) so change TIME survives even when content must +# not. (3) FIRST-CLASS CAUSE LINE +# (acceptance c): on a change (or deletion — ABSENT is a state, +# not an error), one class=actionable entry is enqueued via the +# store allocator with locators {kind:preimage, path (§2.1 hard +# locator — never quarantined), observed_hash, prev_hash, +# preimage:true, reason:preimage-definition-changed}. Both +# detector.sh cmd_poll_once and reconcile.sh cmd_reconcile run +# the check as a PRE-step, so the cause line lands at a LOWER +# observed_seq than the N per-source deltas/enumerations it +# explains — "preimage definition changed" reads first, not N +# UNACCOUNTED lines. (4) FAIL-LOUD discipline (D2/#955 class): +# an unresolvable adapter, unreadable watch-list, corrupt ledger +# (REFUSES to compare or re-baseline over corrupt history), failed +# object/ledger write, or failed enqueue is a loud non-zero — +# never read as "no change"; in both integrations the pass exits +# non-zero but source observation still proceeds (no starvation). +# An ABSENT/EMPTY ledger with objects/ NON-empty is a loud +# non-zero REFUSAL to re-baseline (#964-B11: objects with no +# ledger cannot be a first install — history was deleted; the +# first-install path must not silently absorb it). First-seen on +# a genuinely clean state dir is a SILENT baseline (detector +# first-poll idiom). +# The installer is UNCHANGED — Gate A auto-enumerates the new +# file from the filesystem; the recording site is the runtime +# tick, which is where the env-derived preimage set exists. +# store.sh/digest.sh/beacon.sh UNCHANGED; watch-list schema +# UNTOUCHED ([1,1]). Changed: preimage.sh (new), detector.sh, +# reconcile.sh (+ test-wake-preimage.sh P1-P17; P13-P17 are the +# #964 re-verdict regression needles: symlinked store, live +# secret value, polarity, ledger deletion, allowlist-symlink). component=wake -version=0.6.15 +version=0.7.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 @@ -446,6 +518,19 @@ schema_max=1 # seed) instead of a bare source error, and LINKS the unit into # the user systemd search path + post-install-validates it # resolves (#913). (W7, #913) +# preimage.sh A11 — operator-side PREIMAGE-DEFINITION provenance: derives the +# preimage set from the runtime env (resolved adapter file, +# watch-list, WAKE_PREIMAGE_EXTRA), appends +# {ts,path,sha256,size,mtime,prev} rows to an append-only +# ledger, captures bytes content-addressed under +# preimage/objects/, and enqueues a FIRST-CLASS +# "preimage-definition-changed" actionable (path = §2.1 hard +# locator) BEFORE the deltas it explains (detector + +# reconcile pre-step). Credential HARD GATE: capture is +# REFUSED (hash/mtime still recorded) for credential-store +# paths, secret-shaped content, and oversized files — +# refusal, never redaction. Fail-loud on any infra failure; +# a corrupt ledger refuses re-baseline. (#958) # 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/preimage.sh b/packages/mosaic/framework/tools/wake/preimage.sh new file mode 100755 index 00000000..40f09eae --- /dev/null +++ b/packages/mosaic/framework/tools/wake/preimage.sh @@ -0,0 +1,571 @@ +#!/usr/bin/env bash +# preimage.sh — A11 of the wake canon (#958): durable PROVENANCE for the +# OPERATOR-SIDE preimage definition. +# +# THE GAP THIS CLOSES (#958): every observed_hash in the lane store is +# sha256(adapter stdout) — the operator's source adapter (plus the watch-list +# and any operator-declared preimage files) IS the preimage definition for +# every hash the pipeline ever records. Those files live OUTSIDE the versioned +# wake component, so a byte change to them was attributable only through agent +# transcripts: provenance thinner than any hash it feeds. A changed preimage +# also re-baselines EVERY source at once, which previously surfaced only as N +# per-source deltas / UNACCOUNTED lines with no first-class cause. +# +# WHAT THIS TOOL DOES: +# - Derives the PREIMAGE SET generically (no operator paths baked in): +# * the resolved file behind WAKE_DETECTOR_SOURCE_CMD (first word), +# * the WAKE_WATCH_LIST file, +# * operator-declared extras via WAKE_PREIMAGE_EXTRA (colon-separated — +# e.g. sink/feed scripts). Extras are RECORD-ONLY by default (see the +# credential hard gate below); never list key-material files. +# - Records each file's (sha256, size, mtime, ts) as an APPEND-ONLY ledger +# row in /preimage/preimage-ledger.jsonl and stores the full bytes +# CONTENT-ADDRESSED at /preimage/objects/ — so a change is +# attributable (prior bytes + change time) from durable state alone, with +# no transcript required. Acceptance (a) of #958. +# - On a CHANGE (not first-seen), `check --enqueue` enqueues ONE first-class +# class=actionable entry per changed file via the store's single allocator +# (#908), carrying a §2.1 hard locator (`path`), BEFORE the per-source +# deltas it explains are observed — so the digest shows the cause line, +# not just N re-baselined sources. Acceptance (c). +# +# CREDENTIAL HARD GATE — acceptance (b): the mechanism must be UNABLE to +# capture credential material even by operator error. Layered, every layer +# fail-closed toward NOT capturing bytes (the hash/size/mtime row is still +# written — change-time attribution survives, content capture does not; +# a sha256 discloses nothing about the bytes): +# 1. POLARITY (#964 review, case D): byte capture is DENY-BY-DEFAULT for +# operator extras. WAKE_PREIMAGE_EXTRA paths are RECORD-ONLY (rows + +# first-class change entries, no bytes) unless explicitly listed in +# WAKE_PREIMAGE_CAPTURE — a shape list can only refuse the secrets +# someone already enumerated, so arbitrary operator-pointed files must +# not default to capture. The CORE set (the resolved adapter file, the +# watch-list) is capture-eligible: it IS the preimage definition this +# tool exists to snapshot, and the deny layers below still apply to it. +# 2. PATH deny: the mosaic credential store locations +# (/credentials.json, /tools/_lib/ +# credentials.json, anything under /credentials/, and any +# file literally named credentials.json) are refused. Evaluated on BOTH +# the operator-supplied form AND the symlink-resolved form, against BOTH +# the unresolved and resolved forms of the mosaic-home anchor — a deny +# list written only in unresolved paths cannot match a path that was +# resolved before it arrived (#964 review, case C). +# 3. CONTENT deny: a candidate whose bytes match the well-known secret-token +# shapes (same conservative set digest.sh redacts: PAT/Slack/AKIA/JWT/PEM) +# OR a named-assignment secret shape (key/token/secret/passw/hmac/ +# credential/bearer = long unbroken value — catches prefix-less +# high-entropy keys, e.g. an HMAC key in an env file) is refused — +# refusal, not redaction: a redacted preimage would be a false witness, +# and secret bytes must never land in the object store. Deliberately +# conservative toward REFUSAL: a false match only withholds byte capture, +# never tracking. +# Plus a size cap (WAKE_PREIMAGE_MAX_BYTES, default 1 MiB) so a stray extra +# pointing at a large binary cannot turn provenance into data hoovering. +# Deny ALWAYS wins over the WAKE_PREIMAGE_CAPTURE opt-in. +# +# FAIL-LOUD DISCIPLINE (the D2/#955 class, both layers): an infrastructure +# failure of THIS tool (unresolvable adapter, unreadable/corrupt ledger, +# failed durable write, failed enqueue) exits NON-ZERO and is never read as +# "no change". A corrupt ledger REFUSES to compare (and to re-baseline): +# silently restarting history would erase the very attribution this exists +# to provide. Likewise (#964 review, B11) an ABSENT/EMPTY ledger while +# objects/ still holds prior captures is DELETED HISTORY, not a first +# install — it refuses loudly instead of re-baselining, because a silent +# restart would absorb the next real change as first-seen. +# +# Operator-agnostic (framework firewall): all state via XDG/env; the deny +# list names only framework-defined credential locations, no operator hosts/ +# names/secrets. This tool never prints file CONTENT to any stream. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./_wake-common.sh disable=SC1091 +. "$SCRIPT_DIR/_wake-common.sh" + +STATE_DIR="$(wake_state_dir)" +STORE_SH="$SCRIPT_DIR/store.sh" +PRE_DIR="$STATE_DIR/preimage" +LEDGER="$PRE_DIR/preimage-ledger.jsonl" +OBJECTS="$PRE_DIR/objects" + +_need_jq() { + command -v jq >/dev/null 2>&1 || { + echo "preimage.sh: jq is required" >&2 + exit 3 + } +} + +# _hash_stdin — sha256 of stdin, first field only (portable, same fallback +# chain as detector.sh). +_hash_stdin() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 | awk '{print $NF}' + else + echo "preimage.sh: no sha256 tool (sha256sum/shasum/openssl) available" >&2 + exit 3 + fi +} + +usage() { + cat >&2 <<'EOF' +Usage: preimage.sh + +Commands: + check [--enqueue] Compare every preimage-set file against the ledger head. + A changed file gets a new ledger row (+ captured bytes + when capture-eligible and not denied — see Environment); + with --enqueue each change (not first-seen) also enqueues + ONE first-class class=actionable store entry (hard + locator: path). First-seen files baseline SILENTLY (row, + no enqueue — same idiom as the detector's first-seen). + Exit 0 whether or not changes were found; non-zero ONLY + on infrastructure failure (never read as "no change"). + record Alias of `check` without enqueue (baseline/refresh). + set Print the derived preimage set, one resolved path per + line (diagnostic). + status Print the ledger head row for every tracked path. + +Environment: + WAKE_DETECTOR_SOURCE_CMD Adapter command; its resolved file joins the set. + WAKE_WATCH_LIST Watch-list path; joins the set when set. + WAKE_PREIMAGE_EXTRA Colon-separated additional operator preimage files + (e.g. sink/feed scripts). Extras are RECORD-ONLY + by default: changes get a hash/size/mtime row and + a first-class change entry, but their BYTES are + never captured unless the path is also listed in + WAKE_PREIMAGE_CAPTURE. A listed path that does not + exist is tracked as ABSENT (deletion of a preimage + file is a change, not an error). + WAKE_PREIMAGE_CAPTURE Colon-separated allowlist of extras whose bytes + MAY be captured. The credential deny rules ALWAYS + win over this list. NEVER list files that can hold + key material (env files with keys/HMACs, credential + stores): the deny gate is a backstop, not a + license. + WAKE_PREIMAGE_MAX_BYTES Byte-capture cap (default 1048576). Larger files: + hash/size/mtime recorded, bytes refused. + WAKE_STATE_HOME/WAKE_AGENT store namespace (see store.sh). +EOF +} + +# --- preimage-set derivation (generic; no operator paths baked in) ---------- + +# _realpath_or_self PATH — resolved form when resolvable, the input otherwise. +_realpath_or_self() { + local p="$1" + if command -v realpath >/dev/null 2>&1; then + realpath -- "$p" 2>/dev/null || printf '%s' "$p" + else + printf '%s' "$p" + fi +} + +# _resolve_cmd_file CMD — resolve the FIRST WORD of an adapter command string +# to a real file; prints `rawresolved`. Non-zero (loud) if it cannot be +# resolved: an adapter the detector will invoke but provenance cannot see is +# an infrastructure failure, not a smaller set. BOTH forms are kept: the deny +# gate must see the pre-resolution form too (#964 review, case C). +_resolve_cmd_file() { + local cmd="$1" word path + # shellcheck disable=SC2086 + set -- $cmd + word="${1:-}" + [ -n "$word" ] || return 1 + if [ -f "$word" ]; then + path="$word" + else + path="$(command -v -- "$word" 2>/dev/null)" || return 1 + [ -f "$path" ] || return 1 + fi + # realpath so the ledger keys on the actual file, not a symlink alias. + printf '%s\t%s' "$path" "$(_realpath_or_self "$path")" +} + +# _preimage_set — print the derived set, one member per line as +# `originrawresolved` (origin: core|extra). BOTH path forms travel +# with every member so the deny gate and the capture allowlist are evaluated +# on the SAME candidate in BOTH its forms — resolving before denying is the +# #964 case-C ordering defect. Paths from WAKE_PREIMAGE_EXTRA are printed +# EVEN IF ABSENT (deletion is a tracked state); the adapter and watch-list +# must resolve (loud failure otherwise — see _resolve_cmd_file rationale). +_preimage_set() { + local failed=0 + if [ -n "${WAKE_DETECTOR_SOURCE_CMD:-}" ]; then + local af + if af="$(_resolve_cmd_file "$WAKE_DETECTOR_SOURCE_CMD")"; then + printf 'core\t%s\n' "$af" + else + echo "preimage.sh: FAIL LOUD — WAKE_DETECTOR_SOURCE_CMD ('$WAKE_DETECTOR_SOURCE_CMD') does not resolve to a file; the preimage definition cannot be observed (NOT treated as 'no change')." >&2 + failed=1 + fi + fi + if [ -n "${WAKE_WATCH_LIST:-}" ]; then + if [ -f "$WAKE_WATCH_LIST" ]; then + printf 'core\t%s\t%s\n' "$WAKE_WATCH_LIST" "$(_realpath_or_self "$WAKE_WATCH_LIST")" + else + echo "preimage.sh: FAIL LOUD — WAKE_WATCH_LIST ('$WAKE_WATCH_LIST') is not a file; the preimage definition cannot be observed." >&2 + failed=1 + fi + fi + if [ -n "${WAKE_PREIMAGE_EXTRA:-}" ]; then + local IFS=':' p + for p in $WAKE_PREIMAGE_EXTRA; do + [ -n "$p" ] || continue + # Extras are tracked even when absent (ABSENT is a state). Resolve the + # realpath only when the file exists; otherwise track the literal path. + if [ -e "$p" ]; then + printf 'extra\t%s\t%s\n' "$p" "$(_realpath_or_self "$p")" + else + printf 'extra\t%s\t%s\n' "$p" "$p" + fi + done + fi + return "$failed" +} + +# --- credential hard gate (acceptance (b)) ---------------------------------- + +# _deny_path RAW RESOLVED — 0 (deny) iff EITHER form of the candidate names a +# known credential-store location. Framework-defined locations only +# (firewall): the mosaic credential store, the tools/_lib credential file the +# framework-manifest itself carves out of tools/**, the credentials/ operator +# subtree, and any file literally named credentials.json. +# +# Evaluated on BOTH the operator-supplied (raw) form and the symlink-resolved +# form, against BOTH the unresolved and resolved forms of the mosaic-home +# anchor: a deny list written only in unresolved paths cannot match a path +# that was resolved before it arrived (#964 review, case C — a symlink whose +# target was renamed slipped every path rule because the candidate had +# already been realpath'd but the anchors never were). +_deny_path() { + local raw="$1" resolved="$2" + local home="${MOSAIC_HOME:-$HOME/.config/mosaic}" rhome p a + rhome="$(_realpath_or_self "$home")" + for p in "$raw" "$resolved"; do + [ -n "$p" ] || continue + for a in "$home" "$rhome"; do + case "$p" in + "$a/credentials.json") return 0 ;; + "$a/tools/_lib/credentials.json") return 0 ;; + "$a/credentials/"*) return 0 ;; + esac + done + case "$(basename -- "$p")" in + credentials.json) return 0 ;; + esac + done + return 1 +} + +# _deny_content FILE — 0 (deny) iff FILE's bytes look secret-shaped. +# Two probes: (a) the well-known vendor-token shapes digest.sh redacts +# (PAT / Slack / AKIA / JWT / PEM — conservative: a 40-hex git sha never +# matches); (b) a named-assignment shape (key/token/secret/passw/hmac/ +# credential/bearer = long unbroken value) that catches prefix-less +# high-entropy keys, e.g. an HMAC key in an env file (#964 review, case D). +# +# (b) is defense-in-depth for the OPT-IN path only, NOT the thing that keeps +# case D safe — polarity does that (extras are record-only unless allowlisted; +# a shape list can only refuse the secrets someone already enumerated). It is +# deliberately conservative toward REFUSAL: a false match (a checksum in a +# config, a path that happens to be long and unbroken) only withholds byte +# capture — the hash/size/mtime row and change entry still land. A variable +# REFERENCE (token="$GITEA_TOKEN") never matches: `$` is not in the value +# class — only literal secret values do. +# +# FAILS TOWARD REFUSAL: if a probe itself errors (unreadable file, grep +# failure — rc >= 2), the answer is DENY, not capture. An error exit is not a +# negative result. +_deny_content() { + local rc + LC_ALL=C grep -Eq \ + -e 'gh[pousr]_[A-Za-z0-9]{16,}' \ + -e 'github_pat_[A-Za-z0-9_]{16,}' \ + -e 'xox[baprs]-[A-Za-z0-9-]{10,}' \ + -e 'AKIA[0-9A-Z]{16}' \ + -e 'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}' \ + -e '-----BEGIN[A-Z ]*PRIVATE KEY-----' \ + -- "$1" 2>/dev/null + rc=$? + [ "$rc" -eq 1 ] || return 0 + LC_ALL=C grep -Eiq \ + -e "[A-Za-z0-9_]*(key|token|secret|passw|hmac|credential|bearer)[A-Za-z0-9_]*[[:space:]]*[=:][[:space:]]*[\"']?[A-Za-z0-9+/=_-]{16,}" \ + -- "$1" 2>/dev/null + rc=$? + [ "$rc" -eq 1 ] || return 0 + return 1 +} + +# _capture_allowed ORIGIN RAW RESOLVED — 0 iff byte capture may even be +# ATTEMPTED for this member (the deny gate still runs after, and wins). +# POLARITY (#964 review, case D): core members (the adapter file, the +# watch-list) are capture-eligible — they ARE the preimage definition this +# tool exists to snapshot (acceptance (a)). Extras are RECORD-ONLY unless +# listed in WAKE_PREIMAGE_CAPTURE. +# +# The allowlist is matched with the SAME both-forms discipline as the deny +# list (entry raw/resolved vs candidate raw/resolved): two lists keyed on +# different strings would let a symlink alias slip between them. "Deny wins +# over opt-in" is a precedence rule, not a guarantee both lists see the same +# path — the guarantee comes from _check_one running _deny_path on both forms +# FIRST, independent of anything matched here. +_capture_allowed() { + local origin="$1" raw="$2" resolved="$3" + [ "$origin" = "core" ] && return 0 + [ -n "${WAKE_PREIMAGE_CAPTURE:-}" ] || return 1 + local IFS=':' e er + for e in $WAKE_PREIMAGE_CAPTURE; do + [ -n "$e" ] || continue + er="$e" + [ -e "$e" ] && er="$(_realpath_or_self "$e")" + if [ "$e" = "$raw" ] || [ "$e" = "$resolved" ] || + [ "$er" = "$raw" ] || [ "$er" = "$resolved" ]; then + return 0 + fi + done + return 1 +} + +# --- ledger primitives ------------------------------------------------------ + +# _ledger_head PATH — print the LAST ledger row for PATH (empty if none). +# Non-zero (loud) if the ledger exists but is unparseable: a corrupt ledger +# must REFUSE to compare, never silently re-baseline (that would erase the +# attribution history this tool exists to keep). +_ledger_head() { + local path="$1" + [ -f "$LEDGER" ] || return 0 + if [ -s "$LEDGER" ] && ! jq -cn 'inputs' <"$LEDGER" >/dev/null 2>&1; then + echo "preimage.sh: FAIL LOUD — preimage ledger $LEDGER is unparseable; REFUSING to compare or re-baseline over corrupt history. Investigate/restore the ledger." >&2 + return 1 + fi + jq -c --arg p "$path" 'select(.path == $p)' "$LEDGER" 2>/dev/null | tail -n 1 +} + +# _ledger_append ROW_JSON — append one row atomically (read + append + rename; +# rows are small and the writer is serialized by the preimage lock). +_ledger_append() { + local row="$1" + { + [ -f "$LEDGER" ] && cat "$LEDGER" + printf '%s\n' "$row" + } | _atomic_write "$LEDGER" +} + +# --- the check itself ------------------------------------------------------- + +# _check_one ORIGIN RAW PATH ENQUEUE — compare PATH (the resolved form; the +# ledger keys on it) to its ledger head; on change record (+bytes only if +# capture-eligible and not denied) and optionally enqueue. Prints nothing on +# no-change. Returns: 0 ok (changed or not), 1 infrastructure failure. +_check_one() { + local origin="$1" raw="$2" path="$3" enqueue="$4" + local cur_sha size mtime denied="" refused="" + + if [ -f "$path" ]; then + cur_sha="$(_hash_stdin <"$path")" || return 1 + [ -n "$cur_sha" ] || { + echo "preimage.sh: FAIL LOUD — could not hash $path" >&2 + return 1 + } + size="$(wc -c <"$path" | tr -d '[:space:]')" + # Portable mtime (GNU stat -c / BSD stat -f). + mtime="$(stat -c %Y -- "$path" 2>/dev/null || stat -f %m -- "$path" 2>/dev/null || echo 0)" + local cap="${WAKE_PREIMAGE_MAX_BYTES:-1048576}" + case "$cap" in '' | *[!0-9]*) cap=1048576 ;; esac + # Gate order matters: path deny FIRST, on BOTH forms, INDEPENDENT of the + # capture allowlist — so an allowlisted symlink whose target is denied + # refuses no matter what string the allowlist matched (a known credential + # store is refused without a content probe; the hash read above is + # deliberate: a sha256 discloses nothing about the bytes). Then polarity + # (record-only extras never reach the content probe — case D must be safe + # WITHOUT the shape list), then size cap (never content-grep an oversized + # file), content shape last. + if _deny_path "$raw" "$path"; then + denied="credential-store path (deny list)" + elif ! _capture_allowed "$origin" "$raw" "$path"; then + denied="extra is record-only by default (byte capture requires WAKE_PREIMAGE_CAPTURE; deny rules still win)" + elif [ "$size" -gt "$cap" ]; then + denied="exceeds WAKE_PREIMAGE_MAX_BYTES ($size > $cap)" + elif _deny_content "$path"; then + denied="secret-shaped content" + fi + else + # ABSENT is a state (deletion of a preimage file is a change to record). + cur_sha="ABSENT" + size=0 + mtime=0 + fi + + local head prev_sha="" + head="$(_ledger_head "$path")" || return 1 + [ -n "$head" ] && prev_sha="$(jq -r '.sha256 // ""' <<<"$head")" + + if [ "$cur_sha" = "$prev_sha" ]; then + return 0 # unchanged — no row, no enqueue (idempotent) + fi + + # --- capture bytes (content-addressed) unless the hard gate refuses ------- + local captured=true + if [ "$cur_sha" != "ABSENT" ]; then + if [ -n "$denied" ]; then + captured=false + refused="$denied" + echo "preimage.sh: byte capture WITHHELD for $path ($denied) — hash/size/mtime recorded, content NOT stored (#958)." >&2 + else + mkdir -p "$OBJECTS" || return 1 + if [ ! -f "$OBJECTS/$cur_sha" ]; then + if ! _atomic_write "$OBJECTS/$cur_sha" <"$path"; then + echo "preimage.sh: FAIL LOUD — durable object write failed for $path ($OBJECTS/$cur_sha); NO ledger row recorded (a row whose bytes were never durably kept would be a false witness)." >&2 + return 1 + fi + fi + fi + fi + + local ts row + ts="$(date +%s)" + row="$(jq -cn \ + --arg path "$path" \ + --arg sha "$cur_sha" \ + --arg prev "$prev_sha" \ + --argjson size "$size" \ + --argjson mtime "$mtime" \ + --argjson ts "$ts" \ + --argjson captured "$captured" \ + --arg refused "$refused" \ + '{ts:$ts, path:$path, sha256:$sha, size:$size, mtime:$mtime, captured:$captured} + + (if $prev != "" then {prev:$prev} else {} end) + + (if $refused != "" then {refused:$refused} else {} end)')" || return 1 + if ! _ledger_append "$row"; then + echo "preimage.sh: FAIL LOUD — durable ledger append failed ($LEDGER)" >&2 + return 1 + fi + + if [ -z "$head" ]; then + # First-seen: baseline SILENTLY (row only, no enqueue) — the same idiom as + # the detector's first-seen source baseline. + return 0 + fi + + echo "preimage.sh: preimage definition CHANGED — $path ($prev_sha -> $cur_sha); prior bytes: $OBJECTS/$prev_sha" >&2 + + if [ "$enqueue" = "1" ]; then + # First-class cause line (acceptance (c)): ONE class=actionable entry per + # changed preimage file, allocated by the store's single allocator (#908). + # `path` is a §2.1 hard locator, so the digest renders it — never + # quarantined. Callers run this BEFORE polling sources, so this seq is + # LOWER than the per-source deltas the change explains. + local locators + locators="$(jq -cn \ + --arg path "$path" \ + --arg sha "$cur_sha" \ + --arg prev "$prev_sha" \ + '{kind:"preimage", id:$path, path:$path, observed_hash:$sha, prev_hash:$prev, + preimage:true, reason:"preimage-definition-changed"}')" + if ! "$STORE_SH" enqueue --class actionable --locators "$locators" --emit-ts "$(date +%s)" >/dev/null; then + echo "preimage.sh: FAIL LOUD — store enqueue of the preimage-change entry FAILED for $path (the change IS recorded in the ledger; the first-class wake line is NOT — treat as infrastructure failure)." >&2 + return 1 + fi + fi + return 0 +} + +cmd_check() { + local enqueue=0 + while [ $# -gt 0 ]; do + case "$1" in + --enqueue) + enqueue=1 + shift + ;; + *) + echo "preimage.sh check: unknown option '$1'" >&2 + exit 2 + ;; + esac + done + _need_jq + + local set_list + if ! set_list="$(_preimage_set)"; then + exit 1 + fi + if [ -z "$set_list" ]; then + # Nothing declared (no adapter/watch-list/extras in env) — an empty set is + # a no-op, not an error: standalone invocations outside a wake runtime + # must not fail loud for lacking one. + return 0 + fi + + mkdir -p "$PRE_DIR" || exit 1 + # Serialize concurrent checks (detector tick vs reconcile vs manual) — the + # ledger append is read+rewrite, so two writers must not interleave. + if ! _wake_lock_acquire "$PRE_DIR/preimage.lock"; then + echo "preimage.sh: FAIL LOUD — cannot acquire preimage lock" >&2 + exit 1 + fi + # #964 review (B11): an ABSENT/EMPTY ledger while objects/ still holds + # prior captures is DELETED HISTORY, not a first install — the asymmetry is + # locally detectable and is the whole detection. Re-baselining here would + # silently absorb the next real change as first-seen (the exact erasure + # this tool exists to prevent). ABSENT is not CORRUPT: it must not take the + # first-install path either. + if [ ! -s "$LEDGER" ] && [ -d "$OBJECTS" ] && [ -n "$(ls -A -- "$OBJECTS" 2>/dev/null)" ]; then + echo "preimage.sh: FAIL LOUD — preimage ledger $LEDGER is ABSENT/EMPTY but $OBJECTS still holds prior objects: history was deleted; REFUSING to re-baseline over it (a silent restart would absorb the next real change as first-seen). Restore the ledger, or move objects/ aside explicitly after investigation." >&2 + _wake_lock_release + exit 1 + fi + local failed=0 origin raw resolved + while IFS=$'\t' read -r origin raw resolved; do + [ -n "$resolved" ] || continue + _check_one "$origin" "$raw" "$resolved" "$enqueue" || failed=1 + done <&2 + return 0 + } + # Head row per path (last wins). + jq -cs 'group_by(.path) | map(last) | .[]' "$LEDGER" +} + +cmd_set() { + local s + s="$(_preimage_set)" || exit 1 + [ -n "$s" ] && printf '%s\n' "$s" | cut -f3 +} + +main() { + [ $# -ge 1 ] || { + usage + exit 2 + } + local cmd="$1" + shift + case "$cmd" in + check) cmd_check "$@" ;; + record) cmd_check ;; + set) cmd_set "$@" ;; + status) cmd_status "$@" ;; + -h | --help | help) usage ;; + *) + echo "preimage.sh: unknown command '$cmd'" >&2 + usage + exit 2 + ;; + esac +} + +main "$@" diff --git a/packages/mosaic/framework/tools/wake/reconcile.sh b/packages/mosaic/framework/tools/wake/reconcile.sh index 691f323f..d383975c 100755 --- a/packages/mosaic/framework/tools/wake/reconcile.sh +++ b/packages/mosaic/framework/tools/wake/reconcile.sh @@ -440,6 +440,17 @@ cmd_reconcile() { : "$allow_enumerate" local pairs kind id failed=0 unaccounted=0 enumerated=0 + + # #958 preimage provenance: same pre-step as the detector's poll tick, for + # the path where the preimage changed while the detector was down — without + # it, a changed adapter surfaces here only as N UNACCOUNTED enumerations + # with no first-class cause line. Run BEFORE observing any source so the + # cause entry's observed_seq precedes the enumerations it explains. Loud + # infrastructure failure marks the reconcile failed but does not stop it. + if ! "$SCRIPT_DIR/preimage.sh" check --enqueue; then + echo "reconcile.sh: FAIL LOUD — preimage provenance check failed (see preimage.sh above); reconcile continues but exits non-zero." >&2 + failed=1 + fi pairs="$(printf '%s' "$json" | jq -r '[ .watches[].sources[] | "\(.kind)\t\(.id)" ] | unique | .[]')" if [ -z "$pairs" ]; then echo "reconcile.sh: watch-list declares no sources under watches[].sources[]" >&2 diff --git a/packages/mosaic/framework/tools/wake/test-wake-preimage.sh b/packages/mosaic/framework/tools/wake/test-wake-preimage.sh new file mode 100755 index 00000000..f9058e60 --- /dev/null +++ b/packages/mosaic/framework/tools/wake/test-wake-preimage.sh @@ -0,0 +1,534 @@ +#!/usr/bin/env bash +# test-wake-preimage.sh — RED-FIRST invariant harness for A11 (#958): durable +# provenance for the OPERATOR-SIDE preimage definition (preimage.sh). +# +# Each test asserts ONE #958 invariant and is designed to go RED if it +# regresses: +# P1 first-seen -> SILENT baseline (ledger rows, NO enqueue) (detector idiom) +# P2 change -> attributable from durable state alone: new ledger row with +# prev, PRIOR BYTES retrievable content-addressed, first-class actionable +# enqueued with a §2.1 hard locator (path) (acceptance a+c) +# P3 detector ordering: the preimage cause line is enqueued at a LOWER +# observed_seq than the per-source delta it explains (acceptance c) +# P4 credential-store PATH is refused — bytes NEVER captured, even when the +# operator lists it by error (hash/mtime row still written) (acceptance b) +# P5 secret-SHAPED content is refused — refusal, not redaction (acceptance b) +# P6 deletion of a preimage file is a tracked CHANGE (ABSENT), not an error +# P7 no-change is idempotent (no row growth, no enqueue) +# P8 unresolvable adapter command -> FAIL LOUD, never "no change" (D2/#955 class) +# P9 corrupt ledger -> REFUSE to compare/re-baseline (loud, no append) +# P10 oversized file -> bytes refused, hash still recorded (no data hoovering) +# P11 reconcile pre-step: a preimage change surfaces as a first-class line +# BEFORE the UNACCOUNTED enumerations it explains (acceptance c) +# P12 detector: preimage infra failure is LOUD (pass exits non-zero) but does +# NOT starve source observation +# +# Regression needles from the #964 NOT-CLEAR review (decoy method: plant the +# bytes, grep the WHOLE state dir — the decoy does not know what the code +# believes): +# P13 case C: symlinked/renamed credential store (mosaic-home itself behind +# a symlink; a renamed target outside every path rule) — refused on BOTH +# path forms / by content, bytes grep-absent (acceptance b) +# P14 case D: prefix-less high-entropy key (detector.env class) — safe +# WITHOUT opt-in by POLARITY alone (must hold with the shape list +# deleted), and refused WITH opt-in by the named-assignment shape +# P15 polarity: extras are RECORD-ONLY by default (change row + enqueue, no +# bytes); byte capture requires explicit WAKE_PREIMAGE_CAPTURE opt-in +# P16 case B11: ledger deleted while objects/ survives — LOUD refusal to +# re-baseline; the v2->v3 change is NOT absorbed as first-seen +# P17 allowlist symmetry: an allowlisted symlink whose TARGET is denied +# refuses (deny runs first, on both forms — precedence is not path +# agreement); a symlink to an ALLOWED target still captures (the fix +# must not close case C by breaking every symlinked path) +# +# Uses FAKE/STUB sources only (no live network). Isolated per test. +# shellcheck disable=SC2030,SC2031 +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRE="$SCRIPT_DIR/preimage.sh" +STORE="$SCRIPT_DIR/store.sh" +DET="$SCRIPT_DIR/detector.sh" +RECON="$SCRIPT_DIR/reconcile.sh" + +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_state() { + local d="$TMP_ROOT/$1" + rm -rf "$d" + mkdir -p "$d" + printf '%s' "$d" +} +depth() { "$STORE" cursors | sed -n 's/pending_depth=//p'; } +state_dir() { printf '%s/default' "$WAKE_STATE_HOME"; } +ledger_rows() { + local f + f="$(state_dir)/preimage/preimage-ledger.jsonl" + [ -f "$f" ] && wc -l <"$f" | tr -d '[:space:]' || echo 0 +} + +# make_stub DIR — a source adapter reading $DIR/_ (same shape as the +# reconcile harness stub). +make_stub() { + local dir="$1" + cat >"$dir/adapter.sh" <"$1" <<'EOF' +{ "schema_version": 1, + "repos": [ { "id": "r1" } ], + "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] } +EOF +} + +echo "== P1: first-seen -> SILENT baseline (rows, no enqueue) ==" +( + WAKE_STATE_HOME="$(fresh_state p1)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p1fx"; mkdir -p "$fx" + make_stub "$fx"; make_wl "$fx/wl.json" + printf 'extra v1\n' >"$fx/extra.env" + export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json" WAKE_PREIMAGE_EXTRA="$fx/extra.env" + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P1: baseline check must exit 0 (rc=$rc) [$out]" + [ "$(ledger_rows)" -eq 3 ] || fail_msg "P1: expected 3 baseline rows (adapter, watch-list, extra), got $(ledger_rows)" + [ "$(depth)" = "0" ] || fail_msg "P1: first-seen must NOT enqueue (depth=$(depth))" +) && ok + +echo "== P2: adapter change -> prior bytes + first-class hard-locator enqueue ==" +( + WAKE_STATE_HOME="$(fresh_state p2)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p2fx"; mkdir -p "$fx" + make_stub "$fx"; make_wl "$fx/wl.json" + # Acceptance (a) names the OPERATOR ADAPTER — a core member. Core members + # capture by default (they ARE the preimage definition); extras do not (P15). + export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json" + unset WAKE_PREIMAGE_EXTRA WAKE_PREIMAGE_CAPTURE MOSAIC_HOME + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P2: baseline failed" + cp "$fx/adapter.sh" "$fx/adapter.v1" + old_sha="$(sha256sum "$fx/adapter.sh" | awk '{print $1}')" + printf '# adapter v2 CHANGED\n' >>"$fx/adapter.sh" + new_sha="$(sha256sum "$fx/adapter.sh" | awk '{print $1}')" + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P2: change check must exit 0 (rc=$rc) [$out]" + sd="$(state_dir)" + row="$(jq -c --arg p "$(realpath "$fx/adapter.sh")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)" + [ "$(jq -r '.prev // ""' <<<"$row")" = "$old_sha" ] || fail_msg "P2: new row must carry prev= [$row]" + [ "$(jq -r '.sha256' <<<"$row")" = "$new_sha" ] || fail_msg "P2: new row sha mismatch [$row]" + # Acceptance (a): the PRIOR BYTES are retrievable from durable state alone. + cmp -s "$sd/preimage/objects/$old_sha" "$fx/adapter.v1" || fail_msg "P2: prior bytes missing or not byte-exact" + cmp -s "$sd/preimage/objects/$new_sha" "$fx/adapter.sh" || fail_msg "P2: current bytes object missing or mismatched" + # Acceptance (c): ONE first-class actionable with a §2.1 hard locator (path). + [ "$(depth)" = "1" ] || fail_msg "P2: exactly one enqueue expected (depth=$(depth))" + ent="$(tail -n1 "$sd/pending.jsonl")" + [ "$(jq -r '.class' <<<"$ent")" = "actionable" ] || fail_msg "P2: entry class must be actionable [$ent]" + [ "$(jq -r '.locators.kind' <<<"$ent")" = "preimage" ] || fail_msg "P2: locator kind must be preimage [$ent]" + [ -n "$(jq -r '.locators.path // ""' <<<"$ent")" ] || fail_msg "P2: locator must carry path (hard locator) [$ent]" + [ "$(jq -r '.locators.observed_hash' <<<"$ent")" = "$new_sha" ] || fail_msg "P2: locator observed_hash mismatch [$ent]" + [ "$(jq -r '.locators.prev_hash' <<<"$ent")" = "$old_sha" ] || fail_msg "P2: locator prev_hash mismatch [$ent]" +) && ok + +echo "== P3: detector orders the cause line BEFORE the delta it explains ==" +( + WAKE_STATE_HOME="$(fresh_state p3)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p3fx"; mkdir -p "$fx" + make_stub "$fx"; make_wl "$fx/wl.json" + printf 'r1 state v1\n' >"$fx/repo_r1" + export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json" + unset WAKE_PREIMAGE_EXTRA + "$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: baseline poll failed" + [ "$(depth)" = "0" ] || fail_msg "P3: baseline poll must enqueue nothing (depth=$(depth))" + # Change the ADAPTER (preimage) and the source state in the same window. + printf '# comment: adapter changed\n' >>"$fx/adapter.sh" + printf 'r1 state v2\n' >"$fx/repo_r1" + "$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed" + sd="$(state_dir)" + pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)" + src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)" + [ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued" + [ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued" + if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then + [ "$pre_seq" -lt "$src_seq" ] || fail_msg "P3: cause line must precede the delta (preimage seq=$pre_seq, source seq=$src_seq)" + fi +) && ok + +echo "== P4: credential-store PATH refused — bytes never captured (acceptance b) ==" +( + WAKE_STATE_HOME="$(fresh_state p4)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p4fx"; mkdir -p "$fx" + export MOSAIC_HOME="$fx/mosaic-home" + mkdir -p "$MOSAIC_HOME" + secret='cred-value-P4-do-not-capture' + printf '{"svc":{"token":"%s"}}\n' "$secret" >"$MOSAIC_HOME/credentials.json" + # Operator error: the credential store listed as a preimage extra — AND + # explicitly opted in to capture. Deny must win over the opt-in (#964: a + # path both denied and allowlisted must refuse). + export WAKE_PREIMAGE_EXTRA="$MOSAIC_HOME/credentials.json" + export WAKE_PREIMAGE_CAPTURE="$MOSAIC_HOME/credentials.json" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P4: refusal is not an infra failure (rc=$rc) [$out]" + sd="$(state_dir)" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P4: row must record captured=false [$row]" + jq -r '.refused // ""' <<<"$row" | grep -qi 'path' || fail_msg "P4: refusal must name the path deny [$row]" + # THE invariant: the secret bytes exist NOWHERE under the wake state dir. + if grep -rq "$secret" "$sd" 2>/dev/null; then + fail_msg "P4: credential bytes leaked into the wake state dir" + fi +) && ok + +echo "== P5: secret-SHAPED content refused (refusal, not redaction) ==" +( + WAKE_STATE_HOME="$(fresh_state p5)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p5fx"; mkdir -p "$fx" + tok='ghp_abcdefghijklmnop0123456789ABCDEF' + printf 'export MY_TOKEN=%s\n' "$tok" >"$fx/leaky.env" + # Opted in, so the CONTENT gate (not record-only polarity) is what refuses. + export WAKE_PREIMAGE_EXTRA="$fx/leaky.env" WAKE_PREIMAGE_CAPTURE="$fx/leaky.env" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P5: refusal is not an infra failure (rc=$rc) [$out]" + sd="$(state_dir)" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P5: row must record captured=false [$row]" + jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P5: refusal must name secret-shaped content [$row]" + if grep -rq "$tok" "$sd" 2>/dev/null; then + fail_msg "P5: secret-shaped bytes leaked into the wake state dir" + fi +) && ok + +echo "== P6: deletion is a tracked CHANGE (ABSENT), not an error ==" +( + WAKE_STATE_HOME="$(fresh_state p6)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p6fx"; mkdir -p "$fx" + printf 'to be deleted\n' >"$fx/gone.env" + export WAKE_PREIMAGE_EXTRA="$fx/gone.env" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P6: baseline failed" + rm -f "$fx/gone.env" + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P6: deletion check must exit 0 (rc=$rc) [$out]" + sd="$(state_dir)" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.sha256' <<<"$row")" = "ABSENT" ] || fail_msg "P6: deletion must record sha256=ABSENT [$row]" + [ "$(depth)" = "1" ] || fail_msg "P6: deletion is a change and must enqueue (depth=$(depth))" +) && ok + +echo "== P7: no-change is idempotent ==" +( + WAKE_STATE_HOME="$(fresh_state p7)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p7fx"; mkdir -p "$fx" + printf 'stable\n' >"$fx/stable.env" + export WAKE_PREIMAGE_EXTRA="$fx/stable.env" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P7: baseline failed" + r1="$(ledger_rows)" + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P7: second check failed" + [ "$(ledger_rows)" = "$r1" ] || fail_msg "P7: no-change must not append rows ($r1 -> $(ledger_rows))" + [ "$(depth)" = "0" ] || fail_msg "P7: no-change must not enqueue (depth=$(depth))" +) && ok + +echo "== P8: unresolvable adapter -> FAIL LOUD, never 'no change' (D2 class) ==" +( + WAKE_STATE_HOME="$(fresh_state p8)" + export WAKE_STATE_HOME + unset WAKE_AGENT + export WAKE_DETECTOR_SOURCE_CMD="$TMP_ROOT/does-not-exist-adapter" + unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA MOSAIC_HOME + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "P8: an unobservable preimage definition must FAIL LOUD (rc=0)" + echo "$out" | grep -qi 'does not resolve' || fail_msg "P8: the failure must name the unresolvable adapter [$out]" +) && ok + +echo "== P9: corrupt ledger -> REFUSE to compare/re-baseline ==" +( + WAKE_STATE_HOME="$(fresh_state p9)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p9fx"; mkdir -p "$fx" + printf 'v1\n' >"$fx/f.env" + export WAKE_PREIMAGE_EXTRA="$fx/f.env" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P9: baseline failed" + sd="$(state_dir)" + printf 'NOT-JSON-GARBAGE{{{\n' >>"$sd/preimage/preimage-ledger.jsonl" + r1="$(wc -l <"$sd/preimage/preimage-ledger.jsonl")" + printf 'v2 changed\n' >"$fx/f.env" + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "P9: a corrupt ledger must FAIL LOUD (rc=0)" + echo "$out" | grep -qi 'unparseable' || fail_msg "P9: the failure must name the corrupt ledger [$out]" + [ "$(wc -l <"$sd/preimage/preimage-ledger.jsonl")" = "$r1" ] || fail_msg "P9: nothing may be appended over corrupt history" +) && ok + +echo "== P10: oversized file -> bytes refused, hash still recorded ==" +( + WAKE_STATE_HOME="$(fresh_state p10)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p10fx"; mkdir -p "$fx" + head -c 200 /dev/zero | tr '\0' 'A' >"$fx/big.bin" + # Opted in, so the SIZE gate (not record-only polarity) is what refuses. + export WAKE_PREIMAGE_EXTRA="$fx/big.bin" WAKE_PREIMAGE_MAX_BYTES=64 + export WAKE_PREIMAGE_CAPTURE="$fx/big.bin" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P10: size refusal is not an infra failure (rc=$rc) [$out]" + sd="$(state_dir)" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P10: row must record captured=false [$row]" + jq -r '.refused // ""' <<<"$row" | grep -qi 'MAX_BYTES' || fail_msg "P10: refusal must name the size cap [$row]" + sha="$(jq -r '.sha256' <<<"$row")" + [ "$sha" = "$(sha256sum "$fx/big.bin" | awk '{print $1}')" ] || fail_msg "P10: hash must still be recorded [$row]" + [ ! -f "$sd/preimage/objects/$sha" ] || fail_msg "P10: oversized bytes must NOT be stored" +) && ok + +echo "== P11: reconcile surfaces the cause line before its enumerations ==" +( + WAKE_STATE_HOME="$(fresh_state p11)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p11fx"; mkdir -p "$fx" + make_stub "$fx"; make_wl "$fx/wl.json" + printf 'r1 state\n' >"$fx/repo_r1" + export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json" + unset WAKE_PREIMAGE_EXTRA MOSAIC_HOME + # Baseline the preimage set, then change the adapter while "the detector is + # down" — the reconcile path must still produce the first-class cause line. + "$PRE" check >/dev/null 2>&1 || fail_msg "P11: preimage baseline failed" + printf '# adapter changed while detector down\n' >>"$fx/adapter.sh" + "$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated) + sd="$(state_dir)" + pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)" + enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)" + [ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line" + [ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source" + if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then + [ "$pre_seq" -lt "$enum_seq" ] || fail_msg "P11: cause line must precede the enumeration (preimage seq=$pre_seq, enum seq=$enum_seq)" + fi +) && ok + +echo "== P12: detector — preimage infra failure is LOUD but does not starve observation ==" +( + WAKE_STATE_HOME="$(fresh_state p12)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p12fx"; mkdir -p "$fx" + make_stub "$fx"; make_wl "$fx/wl.json" + printf 'r1 state\n' >"$fx/repo_r1" + export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json" + unset WAKE_PREIMAGE_EXTRA MOSAIC_HOME + # Corrupt the ledger BEFORE the first poll: the preimage check fails loud, + # but the poll must still observe + baseline the source. + sd="$(state_dir)" + mkdir -p "$sd/preimage" + printf 'NOT-JSON-GARBAGE{{{\n' >"$sd/preimage/preimage-ledger.jsonl" + out="$("$DET" poll-once 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "P12: the pass must exit non-zero on preimage infra failure" + echo "$out" | grep -qi 'preimage' || fail_msg "P12: the failure must name the preimage check [$out]" + n="$(find "$sd/detector" -name 'watch-*.hash' 2>/dev/null | wc -l | tr -d '[:space:]')" + [ "$n" -ge 1 ] || fail_msg "P12: source observation must still proceed (no watch hash baselined)" +) && ok + +echo "== P13: symlinked/renamed credential store refused on BOTH path forms (#964 C) ==" +( + WAKE_STATE_HOME="$(fresh_state p13)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p13fx"; mkdir -p "$fx" + # The mosaic home is ITSELF behind a symlink: a deny list written in + # unresolved paths cannot match a candidate that was realpath'd before the + # deny saw it — unless the anchors are resolved too. + mkdir -p "$fx/real-store/credentials" + ln -s "$fx/real-store" "$fx/mh" + export MOSAIC_HOME="$fx/mh" + s1='decoy-P13a-renamed-target-under-store' + printf 'x_token=%s\n' "$s1" >"$fx/mh/credentials/brain-creds.json" + # And a renamed target OUTSIDE every known store location, reached via a + # benign-looking symlink: no path rule can name it — the content gate must + # hold on the resolved file. + s2='0123456789abcdef0123456789abcdefP13b' + printf 'service_hmac_key=%s\n' "$s2" >"$fx/renamed-anywhere.cfg" + ln -s "$fx/renamed-anywhere.cfg" "$fx/link-b.env" + export WAKE_PREIMAGE_EXTRA="$fx/mh/credentials/brain-creds.json:$fx/link-b.env" + # Explicit opt-in for BOTH: deny must win over the allowlist. + export WAKE_PREIMAGE_CAPTURE="$fx/mh/credentials/brain-creds.json:$fx/link-b.env" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P13: refusal is not an infra failure (rc=$rc) [$out]" + sd="$(state_dir)" + n="$(jq -r 'select(.captured == false) | .path' "$sd/preimage/preimage-ledger.jsonl" | wc -l | tr -d '[:space:]')" + [ "$n" = "2" ] || fail_msg "P13: both decoys must record captured=false (got $n)" + # THE invariant (decoy method): the planted bytes exist NOWHERE in state. + if grep -rq "$s1" "$sd" 2>/dev/null; then + fail_msg "P13: renamed-target store bytes leaked into the wake state dir" + fi + if grep -rq "$s2" "$sd" 2>/dev/null; then + fail_msg "P13: prefix-less key bytes leaked into the wake state dir" + fi +) && ok + +echo "== P14: detector.env-class key — safe WITHOUT opt-in by polarity, refused WITH opt-in by shape (#964 D) ==" +( + WAKE_STATE_HOME="$(fresh_state p14)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p14fx"; mkdir -p "$fx" + hm='9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c' + printf 'WAKE_HMAC_KEY=%s\n' "$hm" >"$fx/detector.env" + export WAKE_PREIMAGE_EXTRA="$fx/detector.env" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME WAKE_PREIMAGE_CAPTURE + # (a) NO opt-in: polarity alone keeps the bytes out. This leg must hold even + # with the content shape list deleted — the shape is defense-in-depth for + # the opt-in path, never the thing that makes case D read safe. + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P14a: record-only tracking must succeed (rc=$rc) [$out]" + sd="$(state_dir)" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14a: extra must be record-only without opt-in [$row]" + if grep -rq "$hm" "$sd" 2>/dev/null; then + fail_msg "P14a: key bytes leaked without any opt-in" + fi + # (b) The operator opts the env file in (the exact error the old usage text + # invited): the named-assignment shape must still refuse the bytes. + export WAKE_PREIMAGE_CAPTURE="$fx/detector.env" + printf 'WAKE_HMAC_KEY=%s\nrotated=1\n' "$hm" >"$fx/detector.env" + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P14b: refusal is not an infra failure (rc=$rc) [$out]" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14b: opted-in key material must still refuse [$row]" + jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P14b: refusal must name secret-shaped content [$row]" + if grep -rq "$hm" "$sd" 2>/dev/null; then + fail_msg "P14b: key bytes leaked despite refusal" + fi +) && ok + +echo "== P15: extras are RECORD-ONLY by default; capture requires explicit opt-in ==" +( + WAKE_STATE_HOME="$(fresh_state p15)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p15fx"; mkdir -p "$fx" + printf 'plain v1\n' >"$fx/notes.cfg" + export WAKE_PREIMAGE_EXTRA="$fx/notes.cfg" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME WAKE_PREIMAGE_CAPTURE + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: baseline failed" + printf 'plain v2\n' >"$fx/notes.cfg" + sha2="$(sha256sum "$fx/notes.cfg" | awk '{print $1}')" + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: change check failed" + sd="$(state_dir)" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P15: default must be record-only [$row]" + [ ! -f "$sd/preimage/objects/$sha2" ] || fail_msg "P15: bytes captured without opt-in" + [ "$(depth)" = "1" ] || fail_msg "P15: record-only must still track the change (depth=$(depth))" + # Opt in -> the next change captures. + export WAKE_PREIMAGE_CAPTURE="$fx/notes.cfg" + printf 'plain v3\n' >"$fx/notes.cfg" + sha3="$(sha256sum "$fx/notes.cfg" | awk '{print $1}')" + "$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: opted-in change check failed" + row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")" + [ "$(jq -r '.captured' <<<"$row")" = "true" ] || fail_msg "P15: opt-in must capture [$row]" + cmp -s "$sd/preimage/objects/$sha3" "$fx/notes.cfg" || fail_msg "P15: opted-in bytes missing or mismatched" +) && ok + +echo "== P16: deleted ledger over surviving objects/ — REFUSE to re-baseline (#964 B11) ==" +( + WAKE_STATE_HOME="$(fresh_state p16)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p16fx"; mkdir -p "$fx" + make_stub "$fx" + export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" + unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA WAKE_PREIMAGE_CAPTURE MOSAIC_HOME + "$PRE" check >/dev/null 2>&1 || fail_msg "P16: v1 baseline failed" + printf '# v2\n' >>"$fx/adapter.sh" + "$PRE" check >/dev/null 2>&1 || fail_msg "P16: v2 change failed" + sd="$(state_dir)" + nobj="$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')" + # Delete the ledger; objects/ survives. ABSENT is not CORRUPT — and it is + # not a first install either: that asymmetry is locally detectable. + rm -f "$sd/preimage/preimage-ledger.jsonl" + printf '# v3\n' >>"$fx/adapter.sh" + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "P16: absent ledger over prior objects must FAIL LOUD (rc=0) [$out]" + echo "$out" | grep -qi 'REFUSING to re-baseline' || fail_msg "P16: the failure must name the refusal [$out]" + [ ! -e "$sd/preimage/preimage-ledger.jsonl" ] || fail_msg "P16: the ledger must NOT be silently recreated over deleted history" + [ "$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')" = "$nobj" ] || fail_msg "P16: prior objects must remain untouched" + [ "$(depth)" = "0" ] || fail_msg "P16: the v2->v3 change must NOT be absorbed or enqueued from an unverifiable baseline (depth=$(depth))" +) && ok + +echo "== P17: allowlist symmetry — symlink to a DENIED target refuses; symlink to an allowed target captures ==" +( + WAKE_STATE_HOME="$(fresh_state p17)" + export WAKE_STATE_HOME + unset WAKE_AGENT + fx="$TMP_ROOT/p17fx"; mkdir -p "$fx" + export MOSAIC_HOME="$fx/mh" + mkdir -p "$MOSAIC_HOME" + s='decoy-P17-cred-behind-benign-alias' + printf '{"svc":{"token":"%s"}}\n' "$s" >"$MOSAIC_HOME/credentials.json" + # A benign-looking alias whose TARGET is denied: the allowlist matches the + # alias string, but deny runs FIRST and on BOTH forms — "deny wins over + # opt-in" is a precedence rule, path agreement is what makes it hold. + ln -s "$MOSAIC_HOME/credentials.json" "$fx/settings.json" + # And a symlink to an ALLOWED target: the fix must not close case C by + # breaking every symlinked path outright. + printf 'benign payload v1\n' >"$fx/target.cfg" + ln -s "$fx/target.cfg" "$fx/alias.cfg" + export WAKE_PREIMAGE_EXTRA="$fx/settings.json:$fx/alias.cfg" + export WAKE_PREIMAGE_CAPTURE="$fx/settings.json:$fx/alias.cfg" + unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST + out="$("$PRE" check --enqueue 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "P17: check must exit 0 (rc=$rc) [$out]" + sd="$(state_dir)" + crow="$(jq -c --arg p "$(realpath "$fx/settings.json")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)" + [ "$(jq -r '.captured' <<<"$crow")" = "false" ] || fail_msg "P17: allowlisted symlink to a denied target must refuse [$crow]" + if grep -rq "$s" "$sd" 2>/dev/null; then + fail_msg "P17: credential bytes leaked via an allowlisted alias" + fi + brow="$(jq -c --arg p "$(realpath "$fx/alias.cfg")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)" + [ "$(jq -r '.captured' <<<"$brow")" = "true" ] || fail_msg "P17: symlink to an allowed target must still capture [$brow]" +) && ok + +echo +total=$((pass + $(wc -l <"$FAILFILE"))) +echo "== test-wake-preimage: $pass/$total passed ==" +[ -s "$FAILFILE" ] && exit 1 +exit 0 diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 26f53dc7..1a04cd81 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-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.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" + "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-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.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-preimage.sh && bash framework/tools/wake/test-wake-install.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",