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