fix(wake): #964 re-verdict — record-only polarity, both-forms deny, absent-ledger refusal
ci/woodpecker/pr/ci Pipeline was successful

Answers the NOT CLEAR verdict on #964 (B2 cases C+D, B11), new commit per
the re-verdict terms.

- B2-D (polarity): extras are RECORD-ONLY by default; byte capture for an
  extra requires WAKE_PREIMAGE_CAPTURE opt-in. Core set (adapter,
  watch-list) stays capture-eligible. Case D is safe by polarity alone —
  verified with the content shape list stubbed to always-allow.
- B2-C (both forms): path deny evaluates raw AND realpath-resolved
  candidate forms against unresolved AND resolved mosaic-home anchors;
  deny runs first in the gate chain, so an allowlisted symlink cannot
  smuggle a denied target.
- Content deny is defense-in-depth on the opt-in path only: six scrub
  shapes + named-assignment probe; probe errors fail TOWARD refusal
  (grep rc>=2 is a deny, not a fall-through).
- B11: absent/empty ledger over non-empty objects/ is a loud non-zero
  refusal to re-baseline — that state cannot be a first install.
- Suite: P2 moved to the core adapter; P4/P5/P10 exercise the opt-in
  path; new needles P13-P17 mirror every verdict probe (symlinked store,
  outside-store copy, live secret no-opt-in/opt-in, record-only
  tracking, ledger deletion, allowlist-symlink both halves). Red-first:
  pre-fix code fails exactly P13/P14/P15/P16 (14 assertions); fixed
  17/17. P17's deny half is green pre-fix (basename rule) — kept as a
  guard needle, disclosed in the PR body.

All 10 wake suites green.

Written-by: pepper (sb-it-1-dt)
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NsKce8iZuSuRnu3gVMCBKB
This commit is contained in:
Jason Woltje
2026-07-30 16:29:33 -05:00
co-authored by Claude Fable 5
parent e07943d742
commit 8aff7d8b47
3 changed files with 427 additions and 91 deletions
+193 -68
View File
@@ -16,7 +16,8 @@
# * 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. detector.env, sink/feed scripts).
# 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 <state>/preimage/preimage-ledger.jsonl and stores the full bytes
# CONTENT-ADDRESSED at <state>/preimage/objects/<sha256> — so a change is
@@ -29,28 +30,48 @@
# 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. Enforced two ways, both
# 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. PATH deny: the mosaic credential store locations
# 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
# (<mosaic-home>/credentials.json, <mosaic-home>/tools/_lib/
# credentials.json, anything under <mosaic-home>/credentials/, and any
# file literally named credentials.json) are refused by resolved realpath
# before a single byte is read into the object store.
# 2. CONTENT deny: a candidate whose bytes match the well-known secret-token
# 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)
# is refused — refusal, not redaction: a redacted preimage would be a
# false witness, and secret bytes must never land in the object store.
# 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.
# 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/
@@ -95,7 +116,8 @@ Usage: preimage.sh <command>
Commands:
check [--enqueue] Compare every preimage-set file against the ledger head.
A changed file gets a new ledger row + captured bytes;
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,
@@ -111,9 +133,19 @@ 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. detector.env, sink scripts). A listed path
that does not exist is tracked as ABSENT (deletion
of a preimage file is a change, not an error).
(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).
@@ -122,10 +154,21 @@ 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. 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.
# to a real file; prints `raw<TAB>resolved`. 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
@@ -139,23 +182,22 @@ _resolve_cmd_file() {
[ -f "$path" ] || return 1
fi
# realpath so the ledger keys on the actual file, not a symlink alias.
if command -v realpath >/dev/null 2>&1; then
realpath -- "$path" 2>/dev/null || printf '%s' "$path"
else
printf '%s' "$path"
fi
printf '%s\t%s' "$path" "$(_realpath_or_self "$path")"
}
# _preimage_set — print the derived set, one path per line. 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 — print the derived set, one member per line as
# `origin<TAB>raw<TAB>resolved` (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 '%s\n' "$af"
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
@@ -163,12 +205,7 @@ _preimage_set() {
fi
if [ -n "${WAKE_WATCH_LIST:-}" ]; then
if [ -f "$WAKE_WATCH_LIST" ]; then
if command -v realpath >/dev/null 2>&1; then
realpath -- "$WAKE_WATCH_LIST" 2>/dev/null || printf '%s' "$WAKE_WATCH_LIST"
else
printf '%s' "$WAKE_WATCH_LIST"
fi
printf '\n'
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
@@ -180,11 +217,10 @@ _preimage_set() {
[ -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" ] && command -v realpath >/dev/null 2>&1; then
realpath -- "$p" 2>/dev/null || printf '%s' "$p"
printf '\n'
if [ -e "$p" ]; then
printf 'extra\t%s\t%s\n' "$p" "$(_realpath_or_self "$p")"
else
printf '%s\n' "$p"
printf 'extra\t%s\t%s\n' "$p" "$p"
fi
done
fi
@@ -193,28 +229,59 @@ _preimage_set() {
# --- credential hard gate (acceptance (b)) ----------------------------------
# _deny_path PATH — 0 (deny) iff PATH is 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.
# _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 p="$1" home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
case "$p" in
"$home/credentials.json") return 0 ;;
"$home/tools/_lib/credentials.json") return 0 ;;
"$home/credentials/"*) return 0 ;;
esac
case "$(basename -- "$p")" in
credentials.json) return 0 ;;
esac
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 match a well-known secret-
# token shape. Same conservative shape set digest.sh redacts (PAT / Slack /
# AKIA / JWT / PEM) — conservative on purpose: a 40-hex git sha never matches.
# _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,}' \
@@ -223,6 +290,44 @@ _deny_content() {
-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 ------------------------------------------------------
@@ -253,11 +358,12 @@ _ledger_append() {
# --- the check itself -------------------------------------------------------
# _check_one PATH ENQUEUE — compare PATH to its ledger head; on change record
# (+bytes unless denied) and optionally enqueue. Prints nothing on no-change.
# Returns: 0 ok (changed or not), 1 infrastructure failure.
# _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 path="$1" enqueue="$2"
local origin="$1" raw="$2" path="$3" enqueue="$4"
local cur_sha size mtime denied="" refused=""
if [ -f "$path" ]; then
@@ -271,12 +377,18 @@ _check_one() {
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
# Deny order matters: path first (a known credential store is refused
# without a content probe — the hash read above is deliberate: a sha256
# discloses nothing about the bytes), size cap second (never content-grep
# an oversized file), content shape last.
if _deny_path "$path"; then
# 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
@@ -303,7 +415,7 @@ _check_one() {
if [ -n "$denied" ]; then
captured=false
refused="$denied"
echo "preimage.sh: REFUSED byte capture for $path ($denied) — hash/size/mtime recorded, content NOT stored (acceptance (b), #958)." >&2
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
@@ -397,10 +509,21 @@ cmd_check() {
echo "preimage.sh: FAIL LOUD — cannot acquire preimage lock" >&2
exit 1
fi
local failed=0 p
while IFS= read -r p; do
[ -n "$p" ] || continue
_check_one "$p" "$enqueue" || failed=1
# #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 <<EOF
$set_list
EOF
@@ -419,7 +542,9 @@ cmd_status() {
}
cmd_set() {
_preimage_set || exit 1
local s
s="$(_preimage_set)" || exit 1
[ -n "$s" ] && printf '%s\n' "$s" | cut -f3
}
main() {