From 96609bdade09c5e1fd8a440478b3c5bb5bc3ec52 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 16:54:18 -0500 Subject: [PATCH] framework: prove the wrapper guard both ways, and resolve its wrappers relatively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by running the guard rather than reading it. 1. The guard resolved its sibling wrappers through a hardcoded $HOME/.config/mosaic/tools/git. On a host with no installed mosaic home — a CI container, a bare checkout — every wrapper lookup missed, `[ -x ]` failed, and the guard fell through allowing the raw API write it exists to block. It failed OPEN, silently, in exactly the environment least likely to notice. It now resolves relative to its own path, so it names the wrappers from the install it was launched from, with $HOME as the fallback. 2. There was no test. Adding one surfaced the guard's other sharp edge immediately: it matches the literal text of the Bash command, so a harness that embeds a blocked pattern inline trips the guard on itself rather than on the fixture. That is the correct fail-closed posture and it is now recorded in the test's own comments, because the next person will hit it too. test-wrapper-guard.sh asserts twelve fixtures and asserts the ALLOWED cases as hard as the blocked ones. A guard that over-blocks gets routed around and a guard that under-blocks is decoration; only pinning both edges keeps it useful. It is hermetic — no network, no credentials, no repository — so it joins the CI sanitization step directly rather than the exclusions file. --- .woodpecker/ci.yml | 5 ++ .../framework/tools/git/test-wrapper-guard.sh | 70 +++++++++++++++++++ .../framework/tools/git/wrapper-guard.sh | 9 ++- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100755 packages/mosaic/framework/tools/git/test-wrapper-guard.sh diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index b6b9e30c..ae488a73 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -56,6 +56,11 @@ steps: # and sandboxes a throwaway git repo, so it resolves no real credentials and # joins CI directly rather than the exclusions file. - bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh + # Hermetic behavioural regression for the PreToolUse wrapper guard: proves + # it still blocks the three mistakes AND still lets reads, unwrapped + # endpoints and ordinary commands through. Both directions are asserted — + # a guard that over-blocks gets routed around, which fails just as hard. + - bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh # Blocking gate (#791): a framework upgrade must never write or delete an # operator-owned path. The HARD GATE proves an unanticipated operator sentinel diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh new file mode 100755 index 00000000..8c268ec2 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh. +# +# Resolves no credentials, touches no network, and creates no repository: the +# guard reads a hook payload on stdin and answers with an exit code, so the whole +# contract is testable from fixtures. +# +# The fixtures are written to a temp file rather than passed inline, and this is +# not stylistic. The guard inspects the literal text of the Bash command it is +# handed. A test that embeds `git clone ... $HOME` inside its own command line +# trips the guard on the harness instead of on the fixture — which is exactly +# what happened the first time this was checked by hand. Substring matching over +# whole command text is the guard's deliberate fail-closed posture; a test that +# does not account for it silently measures the wrong thing. +# +# Exit: 0 = every fixture behaved as specified · 1 = at least one did not + +set -uo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +GUARD="${1:-$HERE/wrapper-guard.sh}" +[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; } + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +FIXTURES="$TMP/fixtures.tsv" + +# Each line: TAB TAB +# 0 = allowed, 2 = blocked. +{ + printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n' + printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n' + printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n' + printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n' + printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n' + printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n' + printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n' + printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n' + printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n' + printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n' + printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n' + printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n' +} > "$FIXTURES" + +fail=0 n=0 +while IFS=$'\t' read -r want payload why; do + [ -n "${want:-}" ] || continue + n=$((n + 1)) + printf '%s' "$payload" | "$GUARD" >/dev/null 2>&1 + got=$? + if [ "$got" = "$want" ]; then + printf 'ok %s\n' "$why" + else + printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got" + fail=1 + fi +done < "$FIXTURES" + +printf '\n' +if [ "$fail" -eq 0 ]; then + printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n" +else + cat <<'EOF' +wrapper-guard drifted from its contract. + +A guard that blocks too much gets routed around, and a guard that blocks too +little is decoration. Both directions are failures here, which is why the +allowed cases are asserted as hard as the blocked ones. +EOF +fi +exit "$fail" diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 685ea328..9e815c13 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -39,7 +39,14 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac [ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0 -W="$HOME/.config/mosaic/tools/git" +# The wrappers this guard points at are its own siblings. Resolving relative to +# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard +# names the wrappers from the same install it was launched from, and that it +# still works from a repo checkout with no installed mosaic home (which is how it +# is exercised in CI). $HOME remains the fallback for a guard invoked by an +# absolute path from somewhere unusual. +W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +[ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git" # ---- 1. checkout into $HOME ------------------------------------------------ if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then