fix(wake): #914 digest renderer — WAKE_AGENT-prefixed ack line + digest-class locator threading
Some checks failed
ci/woodpecker/pr/ci Pipeline failed

(a) the embedded ack copy-run line (digest.sh -> ack.sh embed) now bakes an
    explicit WAKE_AGENT=<render-time-agent> prefix (scrubbed via _scrub_inline,
    shell-quoted via printf %q), so an env-less copy-run resolves to the
    correct per-agent namespace instead of silently falling back to 'default'.

(b) _locator_line now also recognizes the locator vocabulary detector.sh (A1)
    actually emits for a digest-class entry (kind/id/observed_hash/remote/path
    /branches, not repo/issue/sha/file), so a digest-class ORIENTATION pointer
    carries a usable (soft) locator instead of rendering empty. Display-only:
    _has_hard_locator and the ACTIONABLE-tier hard-locator FAIL-LOUD (exit 4)
    are unchanged.

RED-FIRST: new D5/D6 groups added to test-wake-digest-hmac.sh, verified RED
against unmodified digest.sh/ack.sh and GREEN after the fix (stash/pop
diffed). H1/H2 (openssl-dependent) are now individually SKIPped instead of
short-circuiting the whole file, so D1-D6 (including the new tests) still run
and pass when openssl is masked from PATH.

Touches only digest.sh + ack.sh + their test harness + the wake manifest
version bump; store.sh/detector.sh/reconcile.sh untouched (avoids collision
with the concurrent #908 work).

Closes #914
Part of #892
This commit is contained in:
mosaic-coder
2026-07-26 00:12:54 -05:00
parent 2378665eaf
commit 2fb15b3aa7
4 changed files with 206 additions and 15 deletions

View File

@@ -54,8 +54,23 @@ Commands:
Local-write only; a background sync
ships it (never blocks on network).
--no-sync suppresses the background ship.
embed --upto N [--wake-id ID] Print the copy-run ack line to EMBED in
embed --upto N [--wake-id ID] [--agent A]
Print the copy-run ack line to EMBED in
a digest (does not perform the ack).
--agent bakes an EXPLICIT
`WAKE_AGENT=A ` prefix onto the emitted
line (render-time namespace), so an
ENV-LESS copy-run still resolves to the
correct per-agent queue instead of
silently falling back to `default`
(#914). The value is shell-quoted
(`printf %q`) so it can never inject
additional shell syntax when the line
is later copy-run; the caller is
expected to have already scrubbed it
(control/ANSI/secrets) the same way
any other inlined digest value is
scrubbed.
status Print ledger tail + cursor state.
Environment:
@@ -210,7 +225,7 @@ cmd_consumed() {
}
cmd_embed() {
local upto='' wake_id=''
local upto='' wake_id='' agent=''
while [ $# -gt 0 ]; do
case "$1" in
--upto)
@@ -221,6 +236,10 @@ cmd_embed() {
wake_id="${2:-}"
shift 2
;;
--agent)
agent="${2:-}"
shift 2
;;
*)
echo "ack.sh embed: unknown option '$1'" >&2
exit 2
@@ -236,10 +255,26 @@ cmd_embed() {
# The one copy-run line a digest embeds. WAKE_ACK_CLI lets an installer point
# it at the deployed path; default is this script's resolved path.
local cli="${WAKE_ACK_CLI:-$SCRIPT_DIR/ack.sh}"
# #914a: bake the render-time agent in as an EXPLICIT `WAKE_AGENT=<agent> `
# prefix, so an ENV-LESS copy-run resolves to the SAME per-agent namespace
# the digest was rendered for, instead of silently falling back to `default`
# (wake_state_dir() resolves purely from ${WAKE_AGENT:-default} at RUN time,
# which — absent this prefix — has no relation to the agent the digest was
# rendered for). `printf %q` shell-quotes the value so it is embedded as one
# opaque token and can never inject additional shell syntax into the line
# when it is later copy-run, even if the (caller-scrubbed) agent value still
# carries shell metacharacters. This is purely additive: no --agent => the
# line is byte-identical to before (existing direct callers unaffected).
local prefix=''
if [ -n "$agent" ]; then
local agent_q
printf -v agent_q '%q' "$agent"
prefix="WAKE_AGENT=${agent_q} "
fi
if [ -n "$wake_id" ]; then
printf '%s consumed --upto %s --wake-id %s\n' "$cli" "$upto" "$wake_id"
printf '%s%s consumed --upto %s --wake-id %s\n' "$prefix" "$cli" "$upto" "$wake_id"
else
printf '%s consumed --upto %s\n' "$cli" "$upto"
printf '%s%s consumed --upto %s\n' "$prefix" "$cli" "$upto"
fi
}

View File

@@ -149,18 +149,40 @@ _has_hard_locator() {
' >/dev/null 2>&1 <<<"$1"
}
# _locator_line LOCATORS_JSON — a scrubbed, one-line hard-locator rendering + a
# one-targeted-call re-verify hint.
# _locator_line LOCATORS_JSON — a scrubbed, one-line locator rendering + a
# one-targeted-call re-verify hint, where available.
#
# #914b: covers BOTH locator vocabularies actually in use:
# - the HARD-locator shape (§2.1, gated by _has_hard_locator, unchanged):
# repo+issue | 40-hex sha | file(:anchor).
# - the shape detector.sh (A1) actually builds for a `digest`-class entry
# (see detector.sh's enqueue-locators jq filter): kind/id/observed_hash +
# whichever of repo/path/anchor/remote/branches the source def declares.
# None of kind/id/observed_hash/remote/path/branches were recognized here
# before, so a real digest-class pointer rendered a bare empty "locator:"
# line despite the entry carrying real (soft, non-hard) locator data. This
# is display-only: it does NOT feed _has_hard_locator, so the
# ACTIONABLE-tier hard-locator FAIL-LOUD gate is untouched.
_locator_line() {
local loc="$1" repo issue sha file anchor head parts='' reverify=''
local remote path kind id ohash branches
repo="$(jq -r '.repo // ""' <<<"$loc")"
issue="$(jq -r '(.issue // "") | tostring' <<<"$loc")"
sha="$(jq -r '.sha // ""' <<<"$loc")"
file="$(jq -r '.file // ""' <<<"$loc")"
anchor="$(jq -r '.anchor // ""' <<<"$loc")"
head="$(jq -r '.head // ""' <<<"$loc")"
remote="$(jq -r '.remote // ""' <<<"$loc")"
path="$(jq -r '.path // ""' <<<"$loc")"
kind="$(jq -r '.kind // ""' <<<"$loc")"
id="$(jq -r '(.id // "") | tostring' <<<"$loc")"
ohash="$(jq -r '.observed_hash // ""' <<<"$loc")"
branches="$(jq -r '(.branches // []) | if length > 0 then join(",") else "" end' <<<"$loc" 2>/dev/null || true)"
[ -n "$kind" ] && parts="$parts kind=$(_scrub_inline "$kind")"
[ "$id" != "" ] && parts="$parts id=$(_scrub_inline "$id")"
[ -n "$head" ] && parts="$parts head=$(_scrub_inline "$head")"
[ -n "$repo" ] && parts="$parts repo=$(_scrub_inline "$repo")"
[ -n "$remote" ] && parts="$parts remote=$(_scrub_inline "$remote")"
[ "$issue" != "" ] && parts="$parts issue=#$(_scrub_inline "$issue")"
[ -n "$sha" ] && parts="$parts sha=$(_scrub_inline "$sha")"
if [ -n "$file" ]; then
@@ -169,8 +191,19 @@ _locator_line() {
else
parts="$parts file=$(_scrub_inline "$file")"
fi
elif [ -n "$path" ]; then
if [ -n "$anchor" ]; then
parts="$parts path=$(_scrub_inline "$path"):$(_scrub_inline "$anchor")"
else
parts="$parts path=$(_scrub_inline "$path")"
fi
fi
# One-targeted-call re-verify hint.
[ -n "$branches" ] && parts="$parts branches=$(_scrub_inline "$branches")"
# observed_hash is a content hash (e.g. sha256 of the polled source), NOT a
# git commit SHA — kept distinct from `sha` so it never impersonates one or
# feeds the `git show <sha>` re-verify hint below.
[ -n "$ohash" ] && parts="$parts observed_hash=$(_scrub_inline "$ohash")"
# One-targeted-call re-verify hint (best available, most-specific first).
if [ -n "$sha" ] && [ -n "$file" ]; then
reverify="git show $(_scrub_inline "$sha"):$(_scrub_inline "$file")"
elif [ -n "$repo" ] && [ "$issue" != "" ]; then
@@ -179,6 +212,12 @@ _locator_line() {
reverify="git show $(_scrub_inline "$sha")"
elif [ -n "$file" ]; then
reverify="re-read $(_scrub_inline "$file")"
elif [ -n "$path" ]; then
reverify="re-read $(_scrub_inline "$path")"
elif [ -n "$kind" ] && [ "$id" != "" ]; then
reverify="re-poll source $(_scrub_inline "$kind")/$(_scrub_inline "$id")"
elif [ -n "$remote" ]; then
reverify="re-read $(_scrub_inline "$remote")"
fi
printf 'locator:%s' "$parts"
[ -n "$reverify" ] && printf '\n re-verify (ONE call): %s' "$reverify"
@@ -350,12 +389,24 @@ cmd_render() {
# Embedded ack copy-run line (W2). CONSUMED is a consumer act; this is the
# exact local-write line the consumer runs after durable capture.
#
# #914a: thread the RENDER-TIME agent into the embedded line as an EXPLICIT
# WAKE_AGENT=<agent> prefix (scrubbed through the same _scrub_inline path
# as every other inlined value — ack.sh additionally shell-quotes it before
# embedding, so it can never become a shell-injection vector when the line
# is later copy-run). Without this, an env-less copy-run resolves
# wake_state_dir() to `${WAKE_AGENT:-default}` = `default`, which silently
# targets the WRONG per-agent namespace (or refuses) unless the consumer's
# ambient shell happens to already export the same WAKE_AGENT. If render
# itself was env-less (agent=="default"), baking "default" is no worse than
# today — the fix wins the common case where WAKE_AGENT was set at render.
printf '\n-- ACK (copy-run; local-write only, never blocks on network) --\n'
local ack_line
local ack_line agent_scrubbed
agent_scrubbed="$(_scrub_inline "$agent")"
if [ -n "$wake_id" ]; then
ack_line="$("$ACK_SH" embed --upto "$observed" --wake-id "$wake_id" 2>/dev/null || true)"
ack_line="$("$ACK_SH" embed --upto "$observed" --agent "$agent_scrubbed" --wake-id "$wake_id" 2>/dev/null || true)"
else
ack_line="$("$ACK_SH" embed --upto "$observed" 2>/dev/null || true)"
ack_line="$("$ACK_SH" embed --upto "$observed" --agent "$agent_scrubbed" 2>/dev/null || true)"
fi
printf '%s\n' "${ack_line:-# ack unavailable}"
} | _redact_secrets

View File

@@ -24,8 +24,19 @@
# 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).
# 0.6.1 #914 digest.sh renderer fixes (live wake-pilot findings): (a) the
# embedded ack copy-run line now bakes an explicit
# WAKE_AGENT=<render-time-agent> prefix (shell-quoted) so an
# env-less copy-run resolves to the correct per-agent namespace
# instead of silently falling back to `default`; (b) the
# ORIENTATION locator renderer now also recognizes the locator
# vocabulary detector.sh (A1) actually emits for a digest-class
# entry (kind/id/observed_hash/remote/path), so a digest-class
# pointer carries a usable (soft) locator instead of rendering
# empty. Display-only: the ACTIONABLE-tier hard-locator FAIL-LOUD
# gate (_has_hard_locator, exit 4) is unchanged.
component=wake
version=0.6.0
version=0.6.1
# Watch-list schema this component consumes, and the INCLUSIVE range of
# schema_version values it supports. A wake-watch-list.json whose schema_version

View File

@@ -19,6 +19,15 @@
# H2 KEY BY-NAME, NEVER INLINE: the key is resolved by NAME from the credential
# store; no flag accepts key material; the key never leaks into output; the
# same-uid threat boundary is documented. (§2.5)
# D5 (#914a) EMBEDDED ACK NAMESPACE: the copy-run ack line rendered at
# WAKE_AGENT=X render time targets X EXPLICITLY, so an env-less copy-run
# still resolves to the correct per-agent namespace (never silently
# falls back to `default`). (§2.1/§2.2)
# D6 (#914b) DIGEST-CLASS LOCATOR THREADING: an ORIENTATION pointer for a
# digest-class entry (the shape detector.sh actually enqueues:
# kind/id/observed_hash/remote/path, not repo/issue/sha/file) carries a
# non-empty, usable locator — and the ACTIONABLE-tier hard-locator
# FAIL-LOUD (exit 4) is UNCHANGED for a malformed actionable claim. (§2.1)
#
# Isolated: every test runs against a fresh temp state / credential file.
set -uo pipefail
@@ -32,10 +41,13 @@ command -v jq >/dev/null 2>&1 || {
echo "SKIP: jq not available" >&2
exit 0
}
command -v openssl >/dev/null 2>&1 || {
echo "SKIP: openssl not available" >&2
exit 0
}
# #914: openssl is required ONLY by H1/H2 (sign.sh, A5). D1-D6 exercise
# digest.sh alone and must still run (and catch a regression) when openssl is
# absent from PATH — a whole-file skip would silently drop that coverage.
# H1/H2 below are individually SKIPped (not the whole file) when openssl is
# unavailable, using the same command -v idiom the other harnesses use.
HAVE_OPENSSL=1
command -v openssl >/dev/null 2>&1 || HAVE_OPENSSL=0
TMP_ROOT="$(mktemp -d)"
trap 'rm -rf "$TMP_ROOT"' EXIT
@@ -215,6 +227,9 @@ echo "== D4: SCRUB — secret-canary + ANSI/bidi/zero-width in source free-text
) && ok
echo "== H1: NON-CIRCULAR HMAC — wake_id independent + not a signed field; tamper breaks MAC =="
if [ "$HAVE_OPENSSL" != "1" ]; then
echo " SKIP: openssl not available (H1 requires sign.sh)" >&2
else
(
MOSAIC_CREDENTIALS_FILE="$(fresh_cred h1 default 'key-material-h1')"
export MOSAIC_CREDENTIALS_FILE
@@ -262,8 +277,12 @@ echo "== H1: NON-CIRCULAR HMAC — wake_id independent + not a signed field; tam
fail_msg "H1: verify passed under a DIFFERENT key (MAC not key-dependent)"
fi
) && ok
fi
echo "== H2: KEY BY-NAME, never inline; no key leak; same-uid boundary documented =="
if [ "$HAVE_OPENSSL" != "1" ]; then
echo " SKIP: openssl not available (H2 requires sign.sh)" >&2
else
(
MOSAIC_CREDENTIALS_FILE="$(fresh_cred h2 signing-key 'SUPERSECRET-KEYVALUE-XYZ')"
export MOSAIC_CREDENTIALS_FILE
@@ -296,6 +315,81 @@ echo "== H2: KEY BY-NAME, never inline; no key leak; same-uid boundary documente
grep -qi 'same-uid' "$SIGN" || fail_msg "H2: same-uid threat boundary not documented in sign.sh"
grep -qi 'off-uid' "$SIGN" || fail_msg "H2: off-uid future signer not named in sign.sh"
) && ok
fi
echo "== D5 (#914a): EMBEDDED ACK NAMESPACE — env-less copy-run targets the RENDER-TIME agent, not 'default' =="
(
WAKE_STATE_HOME="$(fresh_state d5)"
export WAKE_STATE_HOME
# A pending obligation observed under the 'someagent' namespace (render-time
# WAKE_AGENT is known; the bug is that the EMBEDDED line forgets it).
WAKE_AGENT=someagent "$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":41}' >/dev/null
out="$(WAKE_AGENT=someagent "$DIGEST" render)" || fail_msg "D5: render (WAKE_AGENT=someagent) failed"
ack_line="$(printf '%s\n' "$out" | awk '/^-- ACK/{f=1;next} f && NF {print; exit}')"
[ -n "$ack_line" ] || fail_msg "D5: no embedded ack line extracted from the digest"
printf '%s' "$ack_line" | grep -q 'WAKE_AGENT=someagent' ||
fail_msg "D5: embedded ack line has no explicit WAKE_AGENT=someagent prefix — an env-less copy-run silently resolves to 'default' [$ack_line]"
# Actually RUN it with NO WAKE_AGENT in the environment (the real failure
# mode: a consumer copy-pastes the line into a fresh shell).
( unset WAKE_AGENT; env -u WAKE_AGENT sh -c "$ack_line" >/dev/null 2>&1 )
rc=$?
[ "$rc" -eq 0 ] || fail_msg "D5: env-less copy-run of the embedded ack line exited non-zero (rc=$rc) [$ack_line]"
someagent_consumed="$(cat "$WAKE_STATE_HOME/someagent/consumed_seq" 2>/dev/null || echo '?')"
[ "$someagent_consumed" = "1" ] ||
fail_msg "D5: env-less copy-run did NOT advance the someagent namespace's consumed_seq (got '$someagent_consumed') — resolved to the wrong namespace"
# The 'default' namespace must stay untouched — no silent fallback.
if [ -f "$WAKE_STATE_HOME/default/consumed_seq" ]; then
default_consumed="$(cat "$WAKE_STATE_HOME/default/consumed_seq" 2>/dev/null || echo 0)"
[ "$default_consumed" = "0" ] || fail_msg "D5: env-less copy-run advanced the WRONG ('default') namespace instead of someagent"
fi
# --- injection-safety: a hostile agent value must not become a shell
# injection vector when the embedded line is later copy-run, and must be
# scrubbed (control/ANSI bytes stripped) like any other inlined value.
wd="$TMP_ROOT/d5-wd"
rm -rf "$wd"
mkdir -p "$wd"
WAKE_STATE_HOME2="$(fresh_state d5b)"
# shellcheck disable=SC2016 # deliberately literal: this is the injection
# payload under test, not an expression we want the test script to expand.
nasty='$(touch INJECTED)nasty'
WAKE_AGENT="$nasty" WAKE_STATE_HOME="$WAKE_STATE_HOME2" "$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":1}' >/dev/null
out2="$(WAKE_AGENT="$nasty" WAKE_STATE_HOME="$WAKE_STATE_HOME2" "$DIGEST" render)" || fail_msg "D5: render with a hostile agent value failed"
ack_line2="$(printf '%s\n' "$out2" | awk '/^-- ACK/{f=1;next} f && NF {print; exit}')"
( cd "$wd" && unset WAKE_AGENT && env -u WAKE_AGENT WAKE_STATE_HOME="$WAKE_STATE_HOME2" sh -c "$ack_line2" >/dev/null 2>&1 )
[ -e "$wd/INJECTED" ] && fail_msg "D5: a hostile WAKE_AGENT value achieved shell injection via the embedded ack line [$ack_line2]"
true
) && ok
echo "== D6 (#914b): DIGEST-CLASS LOCATOR THREADING — ORIENTATION pointer carries its (soft) locator; ACTIONABLE hard-locator FAIL-LOUD unchanged =="
(
WAKE_STATE_HOME="$(fresh_state d6)"
export WAKE_STATE_HOME
unset WAKE_AGENT WAKE_LANE
# The REAL shape store.sh receives for a digest-class entry, per detector.sh's
# own enqueue-building jq filter (kind/id/observed_hash + whichever of
# repo/path/anchor/remote/branches the source def declares) — NOT
# repo/issue/sha/file, which is what _locator_line originally recognized.
loc="$(jq -cn '{kind:"repo", id:"r1", observed_hash:"deadbeefcafe0123456789abcdef0123456789abcdef0123456789abcdef01", remote:"example/repo"}')"
"$STORE" enqueue --seq 1 --class digest --locators "$loc" >/dev/null
out="$("$DIGEST" render)" || fail_msg "D6: render failed"
orientline="$(printf '%s\n' "$out" | grep -E '^\s*\* seq 1 \[digest\]')"
[ -n "$orientline" ] || fail_msg "D6: no ORIENTATION line found for seq 1"
printf '%s' "$orientline" | grep -qE 'locator: *$' &&
fail_msg "D6: digest-class ORIENTATION pointer rendered an EMPTY locator despite a populated .locators field [$orientline]"
# Must surface something a consumer can act on to re-verify.
printf '%s' "$orientline" | grep -qE 'remote=example/repo|id=r1|kind=repo' ||
fail_msg "D6: digest-class ORIENTATION pointer does not carry a usable locator [$orientline]"
# --- ACTIONABLE-tier hard-locator FAIL-LOUD (exit 4) must be UNCHANGED ------
WAKE_STATE_HOME="$(fresh_state d6b)"
export WAKE_STATE_HOME
"$STORE" enqueue --seq 1 --class actionable --locators '{"claim":"mergeable=true"}' >/dev/null
rc=0
"$DIGEST" render >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 4 ] ||
fail_msg "D6: actionable claim with no hard locator must still FAIL-LOUD exit 4 (got $rc) — regression in the unrelated hard-locator gate"
) && ok
echo
if [ -s "$FAILFILE" ]; then