#!/usr/bin/env bash # Test harness for the ADR-003 consultation gate hooks. # Pipes simulated Claude Code hook JSON into sap-consult-gate.sh and # mark-consult.sh with a temp marker (SAP_CONSULT_MARKER) so the real # .claude/.last-mcp-consult is never touched. # # Usage: bash .claude/hooks/tests/run.sh (exit 0 = all pass) set -u HERE="$(cd "$(dirname "$0")" && pwd)" HOOKS="$(dirname "$HERE")" REPO="$(cd "$HOOKS/../.." && pwd)" GATE="$HOOKS/sap-consult-gate.sh" MARK="$HOOKS/mark-consult.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT STALE="$TMP/stale-marker" FRESH="$TMP/fresh-marker" echo $(( $(date +%s) - 7200 )) > "$STALE" date +%s > "$FRESH" PASS=0 FAIL=0 # gate MARKER JSON -> prints "deny" or "allow"; also asserts exit 0 + valid JSON. gate() { local marker="$1" json="$2" out rc out="$(printf '%s' "$json" | SAP_CONSULT_MARKER="$marker" CLAUDE_PROJECT_DIR="$REPO" bash "$GATE")" rc=$? if [ "$rc" -ne 0 ]; then echo "rc=$rc"; return; fi if [ -n "$out" ] && ! printf '%s' "$out" | jq -e . >/dev/null 2>&1; then echo "badjson"; return; fi if printf '%s' "$out" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then echo deny else echo allow fi } expect() { local name="$1" want="$2" got="$3" if [ "$want" = "$got" ]; then PASS=$((PASS + 1)); echo "PASS $name [$got]" else FAIL=$((FAIL + 1)); echo "FAIL $name [want $want, got $got]" fi } j_edit() { printf '{"tool_name":"Edit","tool_input":{"file_path":"%s","old_string":"a","new_string":"b"}}' "$1"; } j_bash() { printf '{"tool_name":"Bash","tool_input":{"command":%s}}' "$(printf '%s' "$1" | jq -Rs .)"; } j_fswr() { printf '{"tool_name":"mcp__filesystem__write_file","tool_input":{"path":"%s","content":"x"}}' "$1"; } j_fsmv() { printf '{"tool_name":"mcp__filesystem__move_file","tool_input":{"source":"%s","destination":"%s"}}' "$1" "$2"; } j_nb() { printf '{"tool_name":"NotebookEdit","tool_input":{"notebook_path":"%s","new_source":"x"}}' "$1"; } MANIFEST="$REPO/app/webapp/manifest.json" ANNOT="$REPO/app/webapp/annotations/annotation.xml" echo "== sap-consult-gate.sh ==" # --- Cases required by the brief --------------------------------------------- expect "Edit manifest.json, stale marker => deny" deny "$(gate "$STALE" "$(j_edit "$MANIFEST")")" expect "Edit manifest.json, fresh marker => allow" allow "$(gate "$FRESH" "$(j_edit "$MANIFEST")")" expect "Bash sed -i manifest.json, stale => deny" deny "$(gate "$STALE" "$(j_bash "sed -i 's/a/b/' app/webapp/manifest.json")")" expect "Bash cat manifest.json, stale => allow" allow "$(gate "$STALE" "$(j_bash "cat app/webapp/manifest.json")")" expect "Edit worker/src/x.ts, stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/worker/src/x.ts")")" expect "Edit PRD.md, stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/PRD.md")")" expect "fs write_file annotation.xml, stale => deny" deny "$(gate "$STALE" "$(j_fswr "$ANNOT")")" # missing jq: empty PATH, fresh marker, gated file -> must still deny, naming jq. out="$(printf '%s' "$(j_edit "$MANIFEST")" | env -i PATH=/nonexistent SAP_CONSULT_MARKER="$FRESH" /bin/bash "$GATE")"; rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | jq -e '.hookSpecificOutput.permissionDecision == "deny" and (.hookSpecificOutput.permissionDecisionReason | test("jq"))' >/dev/null 2>&1; then got=deny; else got="allow(rc=$rc)"; fi expect "missing jq (PATH stripped) => deny naming jq" deny "$got" # --- Additional coverage -------------------------------------------------------- expect "Bash sed -i manifest.json, fresh => allow" allow "$(gate "$FRESH" "$(j_bash "sed -i 's/a/b/' app/webapp/manifest.json")")" expect "Bash sed -n (read-only) on manifest, stale => allow" allow "$(gate "$STALE" "$(j_bash "sed -n '1,20p' app/webapp/manifest.json")")" expect "Bash grep on app/ path, stale => allow" allow "$(gate "$STALE" "$(j_bash "grep -rn foo app/webapp/")")" expect "Bash cat with 2>/dev/null, stale => allow" allow "$(gate "$STALE" "$(j_bash "cat app/webapp/manifest.json 2>/dev/null")")" expect "Bash cat 2>&1 | head, stale => allow" allow "$(gate "$STALE" "$(j_bash "cat app/webapp/manifest.json 2>&1 | head -5")")" expect "Bash heredoc > .cds, stale => deny" deny "$(gate "$STALE" "$(j_bash "cat > srv/schema.cds <<'X' entity A {} X")")" expect "Bash echo >> manifest.json, stale => deny" deny "$(gate "$STALE" "$(j_bash "echo '{}' >> app/webapp/manifest.json")")" expect "Bash tee annotation.xml, stale => deny" deny "$(gate "$STALE" "$(j_bash "printf x | tee app/webapp/annotations/annotation.xml")")" expect "Bash cp into app/, stale => deny" deny "$(gate "$STALE" "$(j_bash "cp /tmp/x.json app/webapp/manifest.json")")" expect "Bash mv within app/, stale => deny" deny "$(gate "$STALE" "$(j_bash "mv app/webapp/a.xml app/webapp/b.xml")")" expect "Bash python -c writing .abap, stale => deny" deny "$(gate "$STALE" "$(j_bash "python3 -c 'open(\"src/zcl_x.clas.abap\",\"w\").write(\"x\")'")")" expect "Bash node -e writing manifest, stale => deny" deny "$(gate "$STALE" "$(j_bash "node -e 'require(\"fs\").writeFileSync(\"app/webapp/manifest.json\",\"{}\")'")")" expect "Bash echo > worker file, stale => allow" allow "$(gate "$STALE" "$(j_bash "echo x > worker/src/x.ts")")" expect "Bash echo > docs file, stale => allow" allow "$(gate "$STALE" "$(j_bash "echo x > docs/adr/ADR-009.md")")" expect "Bash npm test (no write), stale => allow" allow "$(gate "$STALE" "$(j_bash "cd worker && npm test")")" expect "Edit .bdef, stale => deny" deny "$(gate "$STALE" "$(j_edit "$REPO/src/zi_x.bdef")")" expect "Edit fragment.xml, stale => deny" deny "$(gate "$STALE" "$(j_edit "$REPO/app/webapp/ext/X.fragment.xml")")" expect "Edit app/README.md (exempt ext), stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/app/README.md")")" expect "Edit .claude/hooks file, stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/.claude/hooks/x.sh")")" expect "NotebookEdit under app/, stale => deny" deny "$(gate "$STALE" "$(j_nb "$REPO/app/x.ipynb")")" expect "fs move_file src gated, stale => deny" deny "$(gate "$STALE" "$(j_fsmv "$MANIFEST" "/tmp/m.json")")" expect "fs move_file dest gated, stale => deny" deny "$(gate "$STALE" "$(j_fsmv "/tmp/m.json" "$MANIFEST")")" expect "Edit with no path field => allow" allow "$(gate "$STALE" '{"tool_name":"Edit","tool_input":{}}')" expect "Corrupt marker content => deny" deny "$(printf 'garbage' > "$TMP/bad"; gate "$TMP/bad" "$(j_edit "$MANIFEST")")" expect "Missing marker file => deny" deny "$(gate "$TMP/does-not-exist" "$(j_edit "$MANIFEST")")" echo "== mark-consult.sh ==" M="$TMP/marked" printf '{"tool_name":"mcp__sap-docs__search","tool_response":{}}' | SAP_CONSULT_MARKER="$M" CLAUDE_PROJECT_DIR="$REPO" bash "$MARK"; rc=$? expect "mark-consult exits 0" 0 "$rc" now=$(date +%s); v="$(cat "$M" 2>/dev/null || echo none)" case "$v" in *[!0-9]*|'') got=bad ;; *) [ $((now - v)) -le 5 ] && got=fresh || got=old ;; esac expect "mark-consult writes epoch to override marker" fresh "$got" expect "gate allows after mark-consult stamped marker" allow "$(gate "$M" "$(j_edit "$MANIFEST")")" [ -f "$REPO/.claude/.last-mcp-consult" ] && before="$(cat "$REPO/.claude/.last-mcp-consult")" || before="absent" [ -f "$REPO/.claude/.last-mcp-consult" ] && after="$(cat "$REPO/.claude/.last-mcp-consult")" || after="absent" expect "real marker untouched by harness" "$before" "$after" echo echo "passed=$PASS failed=$FAIL" [ "$FAIL" -eq 0 ]