#!/usr/bin/env bash
# Tests for lib/kill-guard.mjs and hooks/human-kill-guard.mjs.
#
# The protected target is a throwaway `sleep` this script starts inside human.slice — never a
# real session. Nothing here signals any process it did not create.
#
# Run: bash kill-guard.test.sh (exit 0 = all pass).
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GUARD="$ROOT/lib/kill-guard.mjs"
HOOK="$ROOT/hooks/human-kill-guard.mjs"
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"; }

UID_NUM="$(id -u)"
HUMAN_CG="/user.slice/user-${UID_NUM}.slice/user@${UID_NUM}.service/human.slice"

# A decoy session: a sleep of our own inside human.slice, standing in for a real one.
DECOY_UNIT="kill-guard-decoy-$$.scope"
systemd-run --user --scope --quiet --collect --slice=human.slice --unit="$DECOY_UNIT" \
  sleep 120 >/dev/null 2>&1 &
DECOY_RUNNER=$!
DECOY_PID=""
for _ in $(seq 1 50); do
  DECOY_PID="$(systemctl --user show "$DECOY_UNIT" -p ControlGroup --value 2>/dev/null |
    { read -r cg; [[ -n "$cg" ]] && head -1 "/sys/fs/cgroup$cg/cgroup.procs" 2>/dev/null; })"
  [[ -n "$DECOY_PID" ]] && break
  sleep 0.1
done
cleanup() {
  [[ -n "$DECOY_PID" ]] && kill "$DECOY_PID" 2>/dev/null
  wait "$DECOY_RUNNER" 2>/dev/null
  systemctl --user reset-failed "$DECOY_UNIT" 2>/dev/null
}
trap cleanup EXIT

if [[ -z "$DECOY_PID" ]]; then
  bad "setup: decoy session in human.slice" "no pid appeared in $DECOY_UNIT"
  printf 'kill-guard: pass=%d fail=%d\n' "$PASS" "$FAIL"
  exit 1
fi
ok "setup: decoy session running in human.slice (pid $DECOY_PID)"

denies() { # $1=label $2=command  → expects exit 1
  local out
  out=$(node "$GUARD" "$2" 2>&1); local rc=$?
  if (( rc == 1 )) && [[ "$out" == *Blocked* ]]; then ok "$1"; else bad "$1" "rc=$rc out=${out:0:200}"; fi
}
allows() { # $1=label $2=command  → expects exit 0
  local out
  out=$(node "$GUARD" "$2" 2>&1); local rc=$?
  if (( rc == 0 )); then ok "$1"; else bad "$1" "rc=$rc out=${out:0:200}"; fi
}

denies "cgroup.kill on human.slice"        "echo 1 > /sys/fs/cgroup$HUMAN_CG/cgroup.kill"
denies "cgroup.kill on the decoy's scope"  "echo 1 | tee /sys/fs/cgroup$HUMAN_CG/$DECOY_UNIT/cgroup.kill"
denies "cgroup.kill on an ancestor"        "echo 1 > /sys/fs/cgroup/user.slice/user-${UID_NUM}.slice/cgroup.kill"
allows "kill on one literal pid inside human.slice" "kill -9 $DECOY_PID"
denies "systemctl --user stop human.slice" "systemctl --user stop human.slice"
denies "systemctl --user kill the scope"   "systemctl --user kill $DECOY_UNIT"
denies "cgroup.kill via a variable path"   'echo 1 > "$CG/cgroup.kill"'
denies "kill of a non-literal pid"         'kill -9 $(pgrep -f something)'
denies "fuser -k"                          "fuser -k /some/path"
allows "deck-sudo preserves exact-pid allowance" "deck-sudo kill -9 $DECOY_PID"

# The vte-spawn scope case: today's actual incident. Only assert it when a terminal tab
# holding a runtime exists, so the test is honest on a headless machine.
VTE_CG="$(for p in $(pgrep -u "$UID_NUM" -f 'bin/claude|bin/codex|bin/cursor-agent' 2>/dev/null); do
  awk -F: '$1=="0"{print $3}' "/proc/$p/cgroup" 2>/dev/null; done | grep -m1 'vte-spawn-.*\.scope')"
if [[ -n "$VTE_CG" ]]; then
  denies "cgroup.kill on a terminal tab holding a runtime" "echo 1 > /sys/fs/cgroup$VTE_CG/cgroup.kill"
else
  printf 'SKIP terminal-tab case: no vte-spawn scope currently holds a runtime\n'
fi

allows "a command with no kill in it"      "ls -la /tmp"
allows "kill -0 liveness probe"            "kill -0 $DECOY_PID"
allows "kill of the shell's own job"       'kill %1'
allows "kill of the shell's own child"     'kill $!'
allows "stopping an unrelated inactive unit" "systemctl --user stop kill-guard-nonexistent-$$.service"
allows "grepping for cgroup.kill (forensics)" "grep -rn cgroup.kill /sys/fs/cgroup/user.slice"
allows "reading a cgroup.kill file"          "cat /sys/fs/cgroup/user.slice/cgroup.kill"

# argv form (the pkill/killall PATH shim path) must reach the same verdict.
if node "$GUARD" --argv pkill -f "sleep 120" >/dev/null 2>&1; then
  bad "argv form denies pkill -f matching the decoy" "allowed"
else
  ok "argv form denies pkill -f matching the decoy"
fi
if node "$GUARD" --argv pkill -f "kill-guard-no-such-process-$$" >/dev/null 2>&1; then
  ok "argv form allows pkill -f matching nothing"
else
  bad "argv form allows pkill -f matching nothing" "denied"
fi

# The hook contract: PreToolUse JSON in, deny JSON out.
hook_out=$(printf '{"tool_input":{"command":"systemctl --user kill %s"}}' "$DECOY_UNIT" | node "$HOOK" 2>&1)
if [[ "$hook_out" == *'"permissionDecision":"deny"'* && "$hook_out" == *"Blocked"* ]]; then
  ok "hook emits a deny decision"
else
  bad "hook emits a deny decision" "${hook_out:0:200}"
fi
hook_ok=$(printf '{"tool_input":{"command":"ls -la"}}' | node "$HOOK" 2>&1)
if [[ -z "$hook_ok" ]]; then
  ok "hook stays silent on a harmless command"
else
  bad "hook stays silent on a harmless command" "${hook_ok:0:200}"
fi

# An oversize deny must arrive whole: async stdout + process.exit truncated it at 8 KiB,
# and a truncated body is unparseable JSON, which Claude Code discards -> the gate failed open.
big_cmd=$(python3 -c 'print("; ".join("kill -9 $UNRESOLVED_%d" % i for i in range(200)))')
# The real binary, never `command -v bun`: on the workstation that name is the cpu-guard shim.
bun_bin="${OD_BUN:-}"
for candidate in /usr/bin/bun "$HOME/.bun/bin/bun" /usr/local/bin/bun; do
  [[ -n "$bun_bin" ]] && break
  [[ -x "$candidate" ]] && bun_bin="$candidate"
done
[[ -n "$bun_bin" ]] || bad "a bun runtime is available to test the hook under" "none of /usr/bin/bun, ~/.bun/bin/bun, /usr/local/bin/bun"
for rt in node ${bun_bin:+"$bun_bin"}; do
  big_out=$(python3 -c 'import json,sys; print(json.dumps({"tool_input":{"command":sys.argv[1]}}))' "$big_cmd" | "$rt" "$HOOK" 2>/dev/null)
  if [[ ${#big_out} -le 8192 ]]; then
    bad "$rt oversize deny exceeds one pipe chunk" "${#big_out} bytes"
  elif printf '%s' "$big_out" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d["hookSpecificOutput"]["permissionDecision"]=="deny" else 1)'; then
    ok "$rt emits a complete, parseable oversize deny"
  else
    bad "$rt emits a complete, parseable oversize deny" "${big_out:0:120}"
  fi
done

# No "# raw-ok" bypass: that escape exists in deny-gate, and must NOT exist here.
raw_ok=$(printf '{"tool_input":{"command":"kill -9 %s # raw-ok"}}' "$DECOY_PID" | node "$HOOK" 2>&1)
if [[ "$raw_ok" == *'"permissionDecision":"deny"'* ]]; then
  ok "no # raw-ok bypass"
else
  bad "no # raw-ok bypass" "${raw_ok:0:200}"
fi

# The decoy must still be alive: the guard is a gate, not a killer.
if kill -0 "$DECOY_PID" 2>/dev/null; then
  ok "decoy survived the whole suite"
else
  bad "decoy survived the whole suite" "pid $DECOY_PID is gone"
fi

printf 'kill-guard: pass=%d fail=%d\n' "$PASS" "$FAIL"
(( FAIL == 0 ))
