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

@@ -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