From d967a4a926c908f0c0e2a42b7f48cd36b7ef279f Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 26 Jul 2026 07:13:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(wake):=20#914=20digest=20renderer=20?= =?UTF-8?q?=E2=80=94=20WAKE=5FAGENT-prefixed=20ack=20line=20+=20digest-cla?= =?UTF-8?q?ss=20locator=20threading=20(#916)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jason.woltje Co-committed-by: jason.woltje --- packages/mosaic/framework/tools/wake/ack.sh | 43 +++++++++- .../mosaic/framework/tools/wake/digest.sh | 63 ++++++++++++-- .../mosaic/framework/tools/wake/manifest.txt | 13 ++- .../tools/wake/test-wake-digest-hmac.sh | 84 +++++++++++++++++++ 4 files changed, 192 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/framework/tools/wake/ack.sh b/packages/mosaic/framework/tools/wake/ack.sh index 413b0208..31d0cc47 100755 --- a/packages/mosaic/framework/tools/wake/ack.sh +++ b/packages/mosaic/framework/tools/wake/ack.sh @@ -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= ` + # 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 } diff --git a/packages/mosaic/framework/tools/wake/digest.sh b/packages/mosaic/framework/tools/wake/digest.sh index df02ff0d..cc8f9227 100755 --- a/packages/mosaic/framework/tools/wake/digest.sh +++ b/packages/mosaic/framework/tools/wake/digest.sh @@ -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 ` 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= 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 diff --git a/packages/mosaic/framework/tools/wake/manifest.txt b/packages/mosaic/framework/tools/wake/manifest.txt index b65d5826..20d289f3 100644 --- a/packages/mosaic/framework/tools/wake/manifest.txt +++ b/packages/mosaic/framework/tools/wake/manifest.txt @@ -34,8 +34,19 @@ # burn-before-enqueue (arrow 1), W5 co-feed aliasing (arrow 2), and # the migration-restart silent-swallow (arrow 3, now structurally # impossible — allocation is always > consumed or fails loud). +# 0.6.2 #914 digest.sh renderer fixes (live wake-pilot findings): (a) the +# embedded ack copy-run line now bakes an explicit +# WAKE_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.1 +version=0.6.2 # Watch-list schema this component consumes, and the INCLUSIVE range of # schema_version values it supports. A wake-watch-list.json whose schema_version diff --git a/packages/mosaic/framework/tools/wake/test-wake-digest-hmac.sh b/packages/mosaic/framework/tools/wake/test-wake-digest-hmac.sh index e3eb4592..d6a023b5 100755 --- a/packages/mosaic/framework/tools/wake/test-wake-digest-hmac.sh +++ b/packages/mosaic/framework/tools/wake/test-wake-digest-hmac.sh @@ -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,6 +41,7 @@ command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not available" >&2 exit 0 } +# NOTE: whole-suite openssl-skip retained; unmasking D1-D6 to run in CI is tracked in #912 (they currently fail under the CI env). command -v openssl >/dev/null 2>&1 || { echo "SKIP: openssl not available" >&2 exit 0 @@ -297,6 +307,80 @@ echo "== H2: KEY BY-NAME, never inline; no key leak; same-uid boundary documente grep -qi 'off-uid' "$SIGN" || fail_msg "H2: off-uid future signer not named in sign.sh" ) && ok +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 echo "wake digest/hmac harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2