#!/usr/bin/env bash
# Regression tests for hooks/main-checkout-guard.sh — the PreToolUse gate that blocks
# agent mutation of the SHARED main checkout (Edit/Write into it, and destructive git
# verbs run against it), while leaving worktrees and read-only ops untouched.
# Run: bash main-checkout-guard.test.sh (exit 0 = all pass).
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOOK="$ROOT/hooks/main-checkout-guard.sh"
PASS=0; FAIL=0
ok()  { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n     %s\n' "$1" "$2"; }

TMP=$(mktemp -d "${TMPDIR:-/tmp}/mcg-test-XXXX")
trap 'rm -rf "$TMP"' EXIT

REPO="$TMP/repo"
mkdir -p "$REPO"
git -C "$REPO" init -q
git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t; git -C "$REPO" config commit.gpgsign false
echo x > "$REPO/f"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init
mkdir -p "$REPO/.worktrees/wt1"

WT="$REPO/.worktrees/wt1"

decision() {
  # decision <json> -> "deny" | "allow"
  local out
  out=$(printf '%s' "$1" | bash "$HOOK")
  if [[ -z "$out" ]]; then
    echo allow
  elif printf '%s' "$out" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then
    echo deny
  else
    echo "unexpected:$out"
  fi
}

edit_json() { jq -n --arg cwd "$1" --arg fp "$2" '{tool_name:"Edit", cwd:$cwd, tool_input:{file_path:$fp}}'; }
bash_json()  { jq -n --arg cwd "$1" --arg cmd "$2" '{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}'; }

# A. Edit into the main checkout is blocked.
got=$(decision "$(edit_json "$REPO" "$REPO/f")")
[[ "$got" == deny ]] && ok "main-checkout Edit blocked" || bad "main-checkout Edit blocked" "$got"

# B. Edit into a worktree is allowed.
got=$(decision "$(edit_json "$WT" "$WT/f")")
[[ "$got" == allow ]] && ok "worktree Edit allowed" || bad "worktree Edit allowed" "$got"

# C. Read-only git in the main checkout is allowed.
got=$(decision "$(bash_json "$REPO" "git status")")
[[ "$got" == allow ]] && ok "read-only git in main checkout allowed" || bad "read-only git allowed" "$got"

# D. Destructive git in the main checkout is blocked (each verb).
for verb in "git restore ." "git checkout -- f" "git checkout ." "git stash" "git stash push" "git stash drop" "git clean -fd" "git reset --hard"; do
  got=$(decision "$(bash_json "$REPO" "$verb")")
  [[ "$got" == deny ]] && ok "main-checkout blocked: $verb" || bad "main-checkout blocked: $verb" "$got"
done

# E. Destructive git in a worktree is allowed (no ownership dimension here — that's
#    worktree-lock-gate's job; this gate only cares about main-checkout vs worktree).
got=$(decision "$(bash_json "$WT" "git reset --hard")")
[[ "$got" == allow ]] && ok "destructive git in worktree allowed" || bad "destructive git in worktree allowed" "$got"

# F. cd-prefix bypass: session cwd is the worktree, but the command cd's into the
#    main checkout before running the destructive verb.
got=$(decision "$(bash_json "$WT" "cd $REPO && git checkout -- f")")
[[ "$got" == deny ]] && ok "cd-prefix bypass into main checkout blocked" || bad "cd-prefix bypass blocked" "$got"

# G. A repo that never opted into worktree isolation (no .worktrees dir) is left alone.
REPO2="$TMP/repo2"; mkdir -p "$REPO2"; git -C "$REPO2" init -q
got=$(decision "$(edit_json "$REPO2" "$REPO2/f")")
[[ "$got" == allow ]] && ok "non-opted-in repo left alone" || bad "non-opted-in repo left alone" "$got"

# H. Symlinked path into the main checkout is blocked. ~/.claude/buildbox-hosts.json is a
#    real symlink of this shape; an unresolved compare lets the write through.
ln -s "$REPO" "$TMP/repo-link"
got=$(decision "$(edit_json "$WT" "$TMP/repo-link/f")")
[[ "$got" == deny ]] && ok "symlinked path into main checkout blocked" || bad "symlink into main checkout blocked" "$got"

# I. Symlinked path into a WORKTREE stays allowed — resolution must not over-block.
ln -s "$WT" "$TMP/wt-link"
got=$(decision "$(edit_json "$WT" "$TMP/wt-link/f")")
[[ "$got" == allow ]] && ok "symlinked path into worktree allowed" || bad "symlink into worktree allowed" "$got"

# J-N. The LIVE deploy clone: what every agent's hooks/skills/landers execute from, reached
#      through symlinks (~/.claude/hooks -> <deploy>/modules/workstation/claude/hooks) and
#      carrying no .worktrees of its own, so the main-checkout rule never covered it.
DEPLOY="$TMP/deploy"; mkdir -p "$DEPLOY/modules/hooks"; git -C "$DEPLOY" init -q
ln -s "$DEPLOY/modules/hooks" "$TMP/hooks-link"
export OVERDECK_DEPLOY_DIR="$DEPLOY"

got=$(decision "$(bash_json "$WT" "git -C $REPO checkout -- f")")
[[ "$got" == deny ]] && ok "git -C main checkout blocked" || bad "git -C main checkout blocked" "$got"

got=$(decision "$(bash_json "$WT" "git -C $DEPLOY clean -fd")")
[[ "$got" == deny ]] && ok "git -C deploy clone blocked" || bad "git -C deploy clone blocked" "$got"

# --work-tree points git at a protected root while cwd stays innocent, in both the
# `=` and the separate-argument form.
got=$(decision "$(bash_json "$WT" "git --git-dir=$DEPLOY/.git --work-tree=$DEPLOY clean -fd")")
[[ "$got" == deny ]] && ok "git --work-tree= deploy clone blocked" || bad "git --work-tree= deploy clone blocked" "$got"

got=$(decision "$(bash_json "$WT" "git --work-tree $REPO reset --hard")")
[[ "$got" == deny ]] && ok "git --work-tree main checkout blocked" || bad "git --work-tree main checkout blocked" "$got"

got=$(decision "$(edit_json "$WT" "$DEPLOY/modules/hooks/g.sh")")
[[ "$got" == deny ]] && ok "deploy clone Edit blocked" || bad "deploy clone Edit blocked" "$got"

got=$(decision "$(edit_json "$WT" "$TMP/hooks-link/g.sh")")
[[ "$got" == deny ]] && ok "deploy clone Edit via symlink blocked" || bad "deploy clone Edit via symlink blocked" "$got"

got=$(decision "$(bash_json "$DEPLOY" "git checkout -- modules/hooks/g.sh")")
[[ "$got" == deny ]] && ok "destructive git in deploy clone blocked" || bad "destructive git in deploy clone blocked" "$got"

# The deploy mechanism itself must stay runnable: it carries no destructive verb in the
# command the gate sees, and it is what rebuilds the clone.
got=$(decision "$(bash_json "$DEPLOY" "bash packaging/deploy-local.sh")")
[[ "$got" == allow ]] && ok "deploy-local.sh run in deploy clone allowed" || bad "deploy-local.sh allowed" "$got"

# The repo the deploy clone is built from stays writable in a worktree.
got=$(decision "$(edit_json "$WT" "$WT/f")")
[[ "$got" == allow ]] && ok "worktree Edit still allowed with deploy root set" || bad "worktree Edit with deploy root" "$got"

# O. Non-git writes: git never runs, but the target tree is dirtied all the same — one
#    stray file in the deploy clone aborts every deploy on this machine.
while IFS= read -r cmd; do
  [[ -z "$cmd" ]] && continue
  got=$(decision "$(bash_json "$WT" "$cmd")")
  [[ "$got" == deny ]] && ok "non-git write blocked: $cmd" || bad "non-git write blocked: $cmd" "$got"
done <<EOF
cp /tmp/x $DEPLOY/modules/hooks/g.sh
cp -a /tmp/x "$DEPLOY/modules/hooks/g.sh"
mv /tmp/x $DEPLOY/modules/hooks/g.sh
install -m 755 /tmp/x $DEPLOY/modules/hooks/g.sh
rsync -a /tmp/x/ $DEPLOY/modules/hooks/
cp -t $DEPLOY/modules/hooks /tmp/x
echo hi > $DEPLOY/modules/hooks/g.sh
printf x >> $DEPLOY/modules/hooks/g.sh
git show HEAD:f > $DEPLOY/modules/hooks/g.sh
echo hi | tee $DEPLOY/modules/hooks/g.sh
sed -i s/a/b/ $DEPLOY/modules/hooks/g.sh
sed -i -e s/a/b/ $DEPLOY/modules/hooks/g.sh
perl -pi -e s/a/b/ $DEPLOY/modules/hooks/g.sh
touch $DEPLOY/modules/hooks/new
truncate -s 0 $DEPLOY/modules/hooks/g.sh
dd if=/tmp/x of=$DEPLOY/modules/hooks/g.sh
cd $DEPLOY && echo hi > modules/hooks/g.sh
cp /tmp/x $TMP/hooks-link/g.sh
cp /tmp/x $REPO/f
EOF

# P. The same verbs stay allowed when the protected tree is only the SOURCE, when the
#    target is elsewhere, and for the deploy mechanism itself.
while IFS= read -r cmd; do
  [[ -z "$cmd" ]] && continue
  got=$(decision "$(bash_json "$WT" "$cmd")")
  [[ "$got" == allow ]] && ok "non-git write allowed: $cmd" || bad "non-git write allowed: $cmd" "$got"
done <<EOF
cp $DEPLOY/modules/hooks/g.sh /tmp/x
cat $DEPLOY/modules/hooks/g.sh > /tmp/x
diff $DEPLOY/modules/hooks/g.sh $REPO/f
grep -rn hi $DEPLOY
sed -i s/a/b/ $WT/f
echo hi > $WT/f
touch $TMP/scratch
bash packaging/deploy-local.sh
$DEPLOY/bin/deckctl sync apply claude bin
EOF

# R. A relative write target belongs to the `cd` chain in force where it appears, not to
#    every directory the command visited: chained `cd` is how agents reach a worktree.
got=$(decision "$(bash_json "$REPO" "cd $REPO && cd .worktrees/wt1 && echo hi > out.txt")")
[[ "$got" == allow ]] && ok "cd chain into worktree allowed" || bad "cd chain into worktree allowed" "$got"
got=$(decision "$(bash_json "$REPO" "cd .worktrees/wt1 && cp /tmp/x f")")
[[ "$got" == allow ]] && ok "relative cd into worktree allowed" || bad "relative cd into worktree allowed" "$got"
got=$(decision "$(bash_json "$WT" "cd $REPO && echo hi > out.txt")")
[[ "$got" == deny ]] && ok "cd into main checkout still blocked" || bad "cd into main checkout still blocked" "$got"
got=$(decision "$(bash_json "$REPO" "cd .worktrees/wt1 && cp /tmp/x $DEPLOY/modules/hooks/g.sh")")
[[ "$got" == deny ]] && ok "absolute deploy target still blocked after cd" || bad "absolute deploy target still blocked after cd" "$got"

# S. Unexpanded variables: only the literal prefix is knowable, and denying an unknowable
#    target would block ordinary scripting from a main-checkout cwd for every agent.
got=$(decision "$(bash_json "$REPO" 'S=/tmp/scratch; echo hi > $S/f')")
[[ "$got" == allow ]] && ok "variable target with no literal prefix allowed" || bad "variable target with no literal prefix allowed" "$got"
got=$(decision "$(bash_json "$WT" 'f=g.sh; cp /tmp/x '"$DEPLOY"'/modules/hooks/$f')")
[[ "$got" == deny ]] && ok "literal deploy prefix before variable blocked" || bad "literal deploy prefix before variable blocked" "$got"
# The literal prefix can come from the `cd` chain rather than the target itself.
got=$(decision "$(bash_json "$WT" "cd $REPO && sed -i s/a/b/ \$f")")
[[ "$got" == deny ]] && ok "variable target under a literal cd into main blocked" || bad "variable target under a literal cd into main blocked" "$got"
got=$(decision "$(bash_json "$WT" "cd $DEPLOY && sed -i s/a/b/ \$f")")
[[ "$got" == deny ]] && ok "variable target under a literal cd into deploy blocked" || bad "variable target under a literal cd into deploy blocked" "$got"

# T. Heredoc bodies are data: python/json payloads carry `>` and paths that never reach a
#    shell, but a redirect written after the heredoc marker is real.
got=$(decision "$(bash_json "$WT" "python3 - <<PY
doc = 'echo hi > $DEPLOY/modules/hooks/g.sh'
x = 1 > 0
PY")")
[[ "$got" == allow ]] && ok "heredoc body not read as redirection" || bad "heredoc body not read as redirection" "$got"
got=$(decision "$(bash_json "$WT" "python3 - <<PY > $DEPLOY/modules/hooks/g.sh
print(1)
PY")")
[[ "$got" == deny ]] && ok "redirect after heredoc marker still blocked" || bad "redirect after heredoc marker still blocked" "$got"

# U. A `&&` or `|` inside a quoted argument is data, not a separator.
got=$(decision "$(bash_json "$WT" "sed -i 's/if (a && b)/if (b)/' $WT/plan.js")")
[[ "$got" == allow ]] && ok "quoted separator in sed script allowed" || bad "quoted separator in sed script allowed" "$got"
got=$(decision "$(bash_json "$WT" "sed -i 's/if (a && b)/if (b)/' $DEPLOY/modules/hooks/g.sh")")
[[ "$got" == deny ]] && ok "quoted separator does not hide a deploy target" || bad "quoted separator does not hide a deploy target" "$got"

# Q. `~` is still literal in the command text the gate reads, and ~/.claude/* is exactly
#    how the deploy clone is reached in practice.
mkdir -p "$TMP/home/.claude"
ln -s "$DEPLOY/modules/hooks" "$TMP/home/.claude/hooks"
REAL_HOME="$HOME"; export HOME="$TMP/home"
got=$(decision "$(bash_json "$WT" 'cp /tmp/x ~/.claude/hooks/g.sh')")
[[ "$got" == deny ]] && ok "tilde path into deploy clone blocked" || bad "tilde path into deploy clone blocked" "$got"
export HOME="$REAL_HOME"
unset OVERDECK_DEPLOY_DIR

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]]
