#!/usr/bin/env python3
"""task-3017 회귀 테스트 — ``~/.claude/hooks/gh-token-guard.sh`` (PreToolUse, matcher=Bash).

이 훅은 "봇 토큰이 주입되지 않은 GitHub 쓰기 명령"을 차단한다.
토큰 없이 ``gh`` 를 부르면 ``~/.config/gh/hosts.yml`` 의 회장님 개인 PAT 로 폴백해
PR 작성자·``merged_by`` 가 회장님으로 찍히고 감사 기록이 오염된다.

여기 담긴 케이스는 task-3017 검증 당시 ``/tmp/t3017/{probe,probe2,verify,historical,
r1cases,redline}.py`` 로 돌렸던 판정 집합을 **저장소로 이식**한 것이다.
/tmp 에 의존하지 않으며(자기완결), 훅을 읽기만 하고 수정하지 않는다.

★ 훅 파일이 없거나 못 읽으면 skip 이 아니라 **fail** 이다. 가드 유실 자체가 회귀다.
★ 이 테스트는 명령을 실행하지 않는다. 훅에 stdin JSON 을 먹여 판정만 본다.
"""

from __future__ import annotations

import hashlib
import json
import os
import subprocess
from pathlib import Path
from types import SimpleNamespace

import pytest

HOOK = Path("/home/jay/.claude/hooks/gh-token-guard.sh")
SETTINGS = Path("/home/jay/.claude/settings.json")

DENY = "DENY"
ALLOW = "ALLOW"
NL = "\n"

# 훅 본문 md5 — 스위트 실행 중 훅이 변조되지 않았음을 마지막에 확인한다.
_DIGEST_AT_IMPORT = hashlib.md5(HOOK.read_bytes()).hexdigest() if HOOK.is_file() else None


# --------------------------------------------------------------------------- 헬퍼
def _env(**overrides) -> dict:
    """현재 환경을 복사해 일부 변수만 덮어쓰거나(값) 제거한다(None)."""
    env = dict(os.environ)
    for key, value in overrides.items():
        if value is None:
            env.pop(key, None)
        else:
            env[key] = str(value)
    return env


def judge(command: str, env: dict | None = None) -> SimpleNamespace:
    """훅에 PreToolUse stdin JSON 을 넣고 (verdict, reason, system_message) 를 얻는다."""
    assert HOOK.is_file(), f"가드 유실(회귀): {HOOK} 이 없다"
    assert os.access(HOOK, os.R_OK), f"가드를 읽을 수 없다(회귀): {HOOK}"

    payload = json.dumps(
        {"tool_name": "Bash", "tool_input": {"command": command}}, ensure_ascii=False
    )
    proc = subprocess.run(
        ["bash", str(HOOK)],
        input=payload,
        capture_output=True,
        text=True,
        timeout=120,
        env=env,
    )
    assert proc.returncode == 0, (
        f"훅은 어떤 경우에도 rc=0 이어야 한다. rc={proc.returncode} "
        f"stderr={proc.stderr[:400]!r}"
    )

    out = (proc.stdout or "").strip()
    if not out:
        return SimpleNamespace(verdict=ALLOW, reason="", system_message="", raw="")

    try:
        data = json.loads(out)
    except json.JSONDecodeError:  # pragma: no cover - 훅 출력 파손 시에만
        pytest.fail(f"훅 출력이 JSON 이 아니다: {out[:400]!r}")

    hook_out = data.get("hookSpecificOutput") or {}
    verdict = DENY if hook_out.get("permissionDecision") == "deny" else ALLOW
    return SimpleNamespace(
        verdict=verdict,
        reason=hook_out.get("permissionDecisionReason", "") or "",
        system_message=data.get("systemMessage", "") or "",
        raw=out,
    )


# --------------------------------------------------------------------------- 가드 존재
def test_hook_file_exists_and_is_readable():
    """가드 파일이 삭제/이동/권한상실되면 모든 우회가 그대로 통과하므로 fail 로 잡는다."""
    assert HOOK.is_file(), f"가드 유실(회귀): {HOOK}"
    assert os.access(HOOK, os.R_OK), f"가드 읽기 불가(회귀): {HOOK}"
    body = HOOK.read_text(encoding="utf-8", errors="replace")
    assert body.startswith("#!"), "가드가 셸 스크립트가 아니다"
    assert len(body.splitlines()) > 100, "가드 본문이 비정상적으로 짧다(덮어쓰기 의심)"


def test_hook_is_registered_in_settings_json():
    """settings.json PreToolUse(matcher=Bash) 등록이 빠지면 훅 파일이 멀쩡해도 가드는 죽는다."""
    assert SETTINGS.is_file(), f"settings.json 유실(회귀): {SETTINGS}"
    data = json.loads(SETTINGS.read_text(encoding="utf-8"))
    bash_entries = [
        entry
        for entry in (data.get("hooks", {}).get("PreToolUse") or [])
        if entry.get("matcher") == "Bash"
    ]
    commands = [
        hook.get("command", "")
        for entry in bash_entries
        for hook in (entry.get("hooks") or [])
    ]
    assert any(
        "gh-token-guard.sh" in cmd for cmd in commands
    ), f"PreToolUse(matcher=Bash) 에 gh-token-guard.sh 미등록(회귀): {commands}"


# --------------------------------------------------------------------------- 기본 59케이스
# (번호, 라벨, 명령, 기대판정) — 번호는 /tmp/t3017 검증 당시 케이스 번호를 유지한다.
BASE_CASES = [
    # --- 기준선: 반드시 DENY ---
    (1, "gh pr merge (토큰 없음)", "gh pr merge 251 --squash", DENY),
    (2, "gh api -X PUT contents",
     "gh api -X PUT repos/o/r/contents/a.txt -f message=m", DENY),
    (3, "gh api -X PUT pulls merge",
     "gh api -X PUT repos/o/r/pulls/8/merge -f merge_method=squash", DENY),

    # --- 오탐 방지: 반드시 ALLOW ---
    (4, "토큰 주입 + pr merge",
     'GH_TOKEN="$BOT_GITHUB_TOKEN" gh pr merge 251 --squash', ALLOW),
    (5, "gh pr view", "gh pr view 251 --json state", ALLOW),
    (6, "gh api GET", "gh api repos/o/r/pulls/8", ALLOW),
    (7, "gh pr checks", "gh pr checks 251", ALLOW),
    (8, "gh api GET contents", "gh api /repos/o/r/contents/a.txt", ALLOW),

    # --- 우회 후보: 여기서 ALLOW 면 구멍 ---
    (9, "롱플래그 --method PUT",
     "gh api --method PUT repos/o/r/contents/a.txt -f message=m", DENY),
    (10, "등호형 --method=PUT", "gh api --method=PUT repos/o/r/contents/a.txt", DENY),
    (11, "붙여쓴 -XPUT", "gh api -XPUT repos/o/r/contents/a.txt", DENY),
    (12, "heredoc 동반",
     "gh api -X PUT repos/o/r/contents/a.txt --input - <<EOF" + NL
     + '{"message":"m"}' + NL + "EOF", DENY),
    (13, "herestring <<<", 'gh pr merge 251 --squash <<< ""', DENY),
    (14, "백슬래시 개행 -X PUT",
     "gh api \\" + NL + "  -X PUT \\" + NL + "  repos/o/r/contents/a.txt", DENY),
    (15, "여러 줄인데 토큰은 1행에만",
     'GH_TOKEN="$B" gh api repos/o/r' + NL
     + "gh api -X PUT repos/o/r/contents/a.txt", DENY),
    (16, "GH_TOKEN= 이 주석 안에만 있음",
     "gh api -X PUT repos/o/r/contents/a.txt  # GH_TOKEN= 안씀", DENY),
    (17, "gh 와 pr 사이 개행", "gh pr \\" + NL + "  merge 251 --squash", DENY),
    (18, "파이프 뒤", "echo x | gh api -X PUT repos/o/r/contents/a.txt", DENY),
    (19, "서브셸", "(gh api -X PUT repos/o/r/contents/a.txt)", DENY),
    (20, "gh release upload", "gh release upload v1.0 ./dist/app.zip", DENY),
    (21, "gh api --input 파일 + -X PUT",
     "gh api -X PUT repos/o/r/contents/a.txt --input /tmp/body.json", DENY),
    (22, "-X put 소문자", "gh api -X put repos/o/r/contents/a.txt", DENY),
    (23, "-X  PUT 이중 공백", "gh api -X  PUT repos/o/r/contents/a.txt", DENY),
    (24, "--method=POST git refs 생성",
     "gh api --method=POST repos/o/r/git/refs -f ref=refs/heads/x", DENY),
    (25, "환경변수 별칭 경유", "GH=gh; $GH pr merge 251 --squash", DENY),
    (26, "gh secret set", "gh secret set FOO --body bar", DENY),
    (27, "gh workflow run", "gh workflow run deploy.yml", DENY),
    (28, "gh repo delete", "gh repo delete o/r --yes", DENY),
    (29, "gh pr ready", "gh pr ready 251", DENY),
    (30, "탭 구분자 gh pr merge", "gh\tpr\tmerge 251 --squash", DENY),
    (31, "무해한 heredoc 이 앞에 있고 뒤에 쓰기",
     "cat <<EOF > /tmp/x" + NL + "hi" + NL + "EOF" + NL
     + "gh api -X PUT repos/o/r/contents/a.txt", DENY),
    (32, "앞선 echo 가 GH_TOKEN= 문자열을 포함",
     'echo "GH_TOKEN=" ; gh api -X PUT repos/o/r/contents/a.txt', DENY),
    (33, "bash -c 문자열 안의 쓰기", "bash -c 'gh pr merge 251 --squash'", DENY),
    (34, "gh api -X DELETE", "gh api -X DELETE repos/o/r/git/refs/heads/x", DENY),
    (35, "gh issue comment", "gh issue comment 5 --body hi", DENY),
    (36, "graphql mutation (기본 POST)",
     'gh api graphql -f query=\'mutation { mergePullRequest(input:{pullRequestId:"x"}) '
     "{ clientMutationId } }'", DENY),
    (37, "gh pr merge --admin + 2>&1", "gh pr merge 251 --squash --admin 2>&1", DENY),
    (38, "무관한 선행 env 대입",
     "FOO=bar gh api -X PUT repos/o/r/contents/a.txt", DENY),

    # --- probe2 (39-44) ---
    (39, "주석에 훅 이름 자기언급",
     "gh api -X PUT repos/o/r/contents/a.txt  # per gh-token-guard.sh policy", DENY),
    (40, "-X 와 PUT 사이 탭", "gh api -X\tPUT repos/o/r/contents/a.txt", DENY),
    (41, "쓰기 뒤에 무관한 herestring",
     "gh pr merge 251 --squash" + NL + 'grep -q x <<< "y"', DENY),
    (42, "GH_TOKEN= 을 grep 하는 줄이 앞에 있음",
     "grep GH_TOKEN= /home/jay/workspace/.env.keys" + NL
     + "gh api -X PUT repos/o/r/contents/a.txt", DENY),
    (43, "gh pr create (토큰 없음)", "gh pr create --title t --body b", DENY),
    (44, "GITHUB_TOKEN 주입(정당)",
     'GITHUB_TOKEN="$BOT_GITHUB_TOKEN" gh api -X PUT repos/o/r/contents/a.txt', ALLOW),

    # --- verify.py EXTRA (101-115) : 오탐 회귀 + 추가 우회 ---
    (101, "graphql 읽기 쿼리",
     "gh api graphql -f query='query { viewer { login } }'", ALLOW),
    (102, "gh pr list --json", "gh pr list --json number", ALLOW),
    (103, "gh api 읽기 + --jq", "gh api repos/o/r/pulls/8 --jq .state", ALLOW),
    (104, "토큰 주입 + --method PUT",
     'GH_TOKEN="$BOT_GITHUB_TOKEN" gh api --method PUT repos/o/r/contents/a.txt '
     "-f message=m", ALLOW),
    (105, "ALLOW_OWNER_PAT 탈출구",
     "ALLOW_OWNER_PAT=1 gh pr merge 251 --squash", ALLOW),
    (106, "GH_TOKEN 이 빈 값", "GH_TOKEN= gh pr merge 251 --squash", DENY),
    (107, "gh run list 읽기", "gh run list --limit 5", ALLOW),
    (108, "명시 GET + 필드", "gh api --method GET repos/o/r/issues -f state=open", ALLOW),
    (109, "gh release view 읽기", "gh release view v1.0", ALLOW),
    (110, "같은 셸에서 export 후 쓰기",
     'export GH_TOKEN="$BOT_GITHUB_TOKEN"' + NL + "gh pr merge 251 --squash", ALLOW),
    (111, "heredoc 본문에 실린 무토큰 쓰기",
     "bash <<EOF" + NL + "gh pr merge 251 --squash" + NL + "EOF", DENY),
    (112, "미종료 heredoc 이 뒤 문장을 삼키면 안 됨",
     'echo "x <<EOF"' + NL + "gh pr merge 251 --squash", DENY),
    (113, "graphql mutation + 토큰 주입",
     'GH_TOKEN="$B" gh api graphql -f query=\'mutation { x }\'', ALLOW),
    (114, "gh workflow view 읽기", "gh workflow view deploy.yml", ALLOW),
    (115, "gh 아닌 명령 무간섭", "git status && echo done", ALLOW),
]


@pytest.mark.parametrize(
    "num,label,command,expected",
    BASE_CASES,
    ids=[f"c{c[0]}" for c in BASE_CASES],
)
def test_write_without_bot_token_is_denied_and_reads_are_allowed(
    num, label, command, expected
):
    """토큰 없는 gh 쓰기는 차단, 읽기/토큰주입/탈출구는 통과 — 59케이스 판정 회귀."""
    got = judge(command)
    assert got.verdict == expected, (
        f"#{num} {label}\ncmd={command!r}\n기대={expected} 실제={got.verdict}\n"
        f"사유={got.reason[:500]}"
    )


# --------------------------------------------------------------------------- 실제 사고 2건
# (A) InsuRo PR #251 — heredoc 을 킬스위치처럼 써서 우회됐던 원문 그대로.
INCIDENT_INSURO_251 = (
    "cd /home/jay/projects/InsuRo && cat > /tmp/anu3006/merge.json <<'EOF'\n"
    '{"merge_method":"squash","sha":"5b2f2765d74f9e02e202570122de7cdc7a46ac97",'
    '"commit_title":"[task-3006] \\ud655\\uc7a5 \\ud30c\\uc77c\\ub7ff (#251)"}\n'
    "EOF\n"
    "gh api -X PUT repos/Jeon-Jonghyuk/InsuRo/pulls/251/merge "
    "--input /tmp/anu3006/merge.json 2>&1 | head -10"
)

# (B) InsuWiki PR #8 — 존재하지 않는 파일을 source 해서 토큰이 빈 값이 됐던 원문 그대로.
INCIDENT_INSUWIKI_8 = (
    'source ~/.env.keys 2>/dev/null; export GH_TOKEN="${BOT_GITHUB_TOKEN:-$GH_TOKEN}"\n'
    "gh api -X PUT repos/Jeon-Jonghyuk/InsuWiki/pulls/8/merge \\\n"
    "  -f merge_method=squash \\\n"
    '  -f commit_title="[task-3011] runner (#8)" \\\n'
    "  --jq '{merged,sha,message}' 2>&1"
)


def test_incident_insuro_251_heredoc_bypass_is_blocked():
    """실사고 A(InsuRo #251): heredoc 이 앞에 있어도 뒤의 무토큰 머지는 차단돼야 한다."""
    got = judge(INCIDENT_INSURO_251)
    assert got.verdict == DENY, f"실사고 A 가 다시 통과한다:\n{INCIDENT_INSURO_251}"
    assert "미주입" in got.reason, f"차단 사유가 '토큰 미주입'이 아니다: {got.reason[:400]}"


def test_incident_insuwiki_8_empty_token_bypass_is_blocked(tmp_path):
    """실사고 B(InsuWiki #8): source 대상 파일이 없어 토큰이 빈 값이 되는 머지는 차단돼야 한다.

    사고 조건(``~/.env.keys`` 부재)을 재현하려고 HOME 을 빈 tmp 디렉터리로 고정한다.
    """
    fake_home = tmp_path / "home"
    fake_home.mkdir()
    assert not (fake_home / ".env.keys").exists()

    got = judge(INCIDENT_INSUWIKI_8, env=_env(HOME=fake_home))
    assert got.verdict == DENY, f"실사고 B 가 다시 통과한다:\n{INCIDENT_INSUWIKI_8}"
    assert "공급 파일 부재" in got.reason, (
        f"차단 사유가 'R1 토큰 공급 경로 부재'가 아니다: {got.reason[:400]}"
    )


def test_incident_control_group_real_token_merge_is_allowed(tmp_path):
    """대조군: 실존 파일 source + 토큰 주입 머지는 통과해야 한다(과차단 회귀 방지)."""
    keys = tmp_path / "env.keys"
    keys.write_text("BOT_GITHUB_TOKEN=ghs_dummy_for_test\n", encoding="utf-8")
    command = (
        f"set -a; source {keys}; set +a\n"
        'GH_TOKEN="$BOT_GITHUB_TOKEN" gh api -X PUT '
        "repos/Jeon-Jonghyuk/InsuRo/pulls/256/merge -f merge_method=squash"
    )
    got = judge(command, env=_env(HOME=tmp_path))
    assert got.verdict == ALLOW, f"정상 머지가 차단됐다: {got.reason[:500]}"


# --------------------------------------------------------------------------- R1 / R2
WRITE = (
    'GH_TOKEN="$BOT_GITHUB_TOKEN" gh api -X PUT '
    "repos/Jeon-Jonghyuk/InsuWiki/pulls/8/merge -f merge_method=squash"
)
READ = "gh api repos/Jeon-Jonghyuk/InsuWiki/pulls/8 --jq .state"

R1_R2_IDS = [
    "R1-a", "R1-b", "R1-c", "R1-d", "R1-e", "R1-f",
    "R1-g", "R1-h", "R1-i", "R1-j", "R1-k", "R1-l",
    "R2-a", "R2-b", "R2-c", "R2-d",
]


@pytest.fixture
def keyfiles(tmp_path):
    """R1 판정에 필요한 '실존 파일' / '부재 파일' / '빈 HOME' 을 tmp_path 안에 만든다."""
    real = tmp_path / "env.keys"
    real.write_text("BOT_GITHUB_TOKEN=ghs_dummy_for_test\n", encoding="utf-8")
    gone = tmp_path / "nowhere" / "definitely-not-here.keys"
    home = tmp_path / "home"
    home.mkdir()
    assert real.is_file() and not gone.exists()
    assert not (home / ".env.keys").exists()
    return SimpleNamespace(real=real, gone=gone, home=home)


def _r1_r2_cases(k) -> dict:
    """R1(깨진 토큰 공급 경로)·R2(근거 없는 변수 전개) 케이스 표."""
    return {
        "R1-a": ("실존 파일 source + 토큰주입 write",
                 f"set -a; source {k.real}; set +a{NL}{WRITE}", ALLOW),
        "R1-b": ("부재 파일 source + 토큰주입 write",
                 f"source {k.gone} 2>/dev/null{NL}{WRITE}", DENY),
        "R1-c": ("부재 파일 source + 읽기 gh",
                 f"source {k.gone} 2>/dev/null{NL}{READ}", ALLOW),
        "R1-d": ("미해결 변수 경로 source + write(확정 불가 → 오탐 방지)",
                 f'source "$KEYS_FILE"{NL}{WRITE}', ALLOW),
        "R1-e": ("`.` 축약형 + 부재 파일 + write",
                 f". {k.gone}{NL}{WRITE}", DENY),
        "R1-f": ("~ 전개 + 부재 파일 + write",
                 f"source ~/.env.keys 2>/dev/null{NL}{WRITE}", DENY),
        "R1-g": ("$HOME 전개 + 부재 파일 + write",
                 f'source "$HOME/.env.keys" 2>/dev/null{NL}{WRITE}', DENY),
        "R1-h": ("상대경로 source(확정 불가) + write",
                 f"source .env.keys{NL}{WRITE}", ALLOW),
        "R1-i": ("깨진 source 라도 ALLOW_OWNER_PAT 는 탈출구",
                 f"source {k.gone} 2>/dev/null{NL}ALLOW_OWNER_PAT=1 {WRITE}", ALLOW),
        "R1-j": ("실존+부재 혼합 source (하나라도 부재면 차단)",
                 f"source {k.real}; source {k.gone}{NL}{WRITE}", DENY),
        "R1-k": ("깨진 source 인데 gh 호출 자체가 없음",
                 f"source {k.gone} 2>/dev/null{NL}echo hi", ALLOW),
        "R1-l": ("실존 파일 source + 토큰 미주입 write(기존 규칙으로 차단)",
                 f"set -a; source {k.real}; set +a{NL}"
                 "gh api -X PUT repos/o/r/pulls/8/merge -f merge_method=squash", DENY),
        "R2-a": ("source 없음 + 변수 전개 토큰 + write(경고만, 차단 아님)",
                 'GH_TOKEN="$BOT_GITHUB_TOKEN" gh api --method PUT '
                 "repos/o/r/pulls/8/merge -f merge_method=squash", ALLOW),
        "R2-b": ("같은 명령에서 변수를 직접 정의 → 경고 없음",
                 'BOT_GITHUB_TOKEN=ghs_x GH_TOKEN="$BOT_GITHUB_TOKEN" gh api --method PUT '
                 "repos/o/r/pulls/8/merge -f merge_method=squash", ALLOW),
        "R2-c": ("리터럴 토큰 → 경고 없음",
                 "GH_TOKEN=ghs_literal gh api --method PUT repos/o/r/pulls/8/merge "
                 "-f merge_method=squash", ALLOW),
        "R2-d": ("읽기 호출은 무간섭", f'GH_TOKEN="$BOT_GITHUB_TOKEN" {READ}', ALLOW),
    }


@pytest.mark.parametrize("case_id", R1_R2_IDS)
def test_broken_token_supply_path_is_denied(case_id, keyfiles):
    """토큰 변수가 대입돼 있어도 공급 파일(source 대상)이 없으면 런타임 빈 값 → 차단해야 한다."""
    label, command, expected = _r1_r2_cases(keyfiles)[case_id]
    got = judge(command, env=_env(HOME=keyfiles.home, BOT_GITHUB_TOKEN=None))
    assert got.verdict == expected, (
        f"{case_id} {label}\ncmd={command!r}\n기대={expected} 실제={got.verdict}\n"
        f"사유={got.reason[:500]}"
    )


def test_unbacked_variable_token_warns_but_does_not_block(keyfiles):
    """R2: source 도 없고 훅 환경에도 변수가 없으면 '차단'이 아니라 '경고'여야 한다."""
    _, command, _ = _r1_r2_cases(keyfiles)["R2-a"]
    got = judge(command, env=_env(HOME=keyfiles.home, BOT_GITHUB_TOKEN=None, GH_TOKEN=None))
    assert got.verdict == ALLOW, "R2 는 경고여야 하는데 차단됐다"
    assert "경고" in got.system_message, (
        f"R2 경고 메시지가 사라졌다(회귀): {got.system_message!r}"
    )


def test_literal_token_produces_no_warning(keyfiles):
    """리터럴 토큰은 공급 경로가 자명하므로 경고조차 없어야 한다(경고 남발 방지)."""
    _, command, _ = _r1_r2_cases(keyfiles)["R2-c"]
    got = judge(command, env=_env(HOME=keyfiles.home, BOT_GITHUB_TOKEN=None))
    assert got.verdict == ALLOW
    assert got.system_message == "", f"불필요한 경고: {got.system_message!r}"


# --------------------------------------------------------------------------- 레드라인
REDLINE_IDS = ["N1", "N2", "N3", "N4", "N5", "N6", "N7", "N8", "X1"]


def _redline_cases(keys_path: Path) -> dict:
    """ANU 가 실제로 쓰는 정상 경로 — 여기가 막히면 운영이 정지한다."""
    src = f"set -a; source {keys_path}; set +a\n"
    tok = 'GH_TOKEN="$BOT_GITHUB_TOKEN" '
    return {
        "N1": ("PR 머지 (정본 source + 토큰)",
               src + tok + "gh api -X PUT repos/Jeon-Jonghyuk/InsuRo/pulls/256/merge "
               "-f merge_method=squash", ALLOW),
        "N2": ("PR 머지 (source 없이 토큰만)",
               tok + "gh api -X PUT repos/Jeon-Jonghyuk/InsuRo/pulls/256/merge "
               "-f merge_method=squash", ALLOW),
        "N3": ("PR 상태 조회",
               "gh pr view 256 --repo Jeon-Jonghyuk/InsuRo --json state", ALLOW),
        "N4": ("CI 체크 조회",
               "gh api repos/Jeon-Jonghyuk/InsuRo/commits/main/check-runs", ALLOW),
        "N5": ("PR 생성 (토큰 주입)", src + tok + "gh pr create --title t --body b", ALLOW),
        "N6": ("신규 파일 contents PUT (sha 없음)",
               src + tok + "gh api -X PUT repos/Jeon-Jonghyuk/InsuRo/contents/new.txt "
               "-f message=m -f content=eA== -f branch=main", ALLOW),
        "N7": ("일반 셸 명령", "git status --short && ls -la", ALLOW),
        "N8": ("조회 작업 (--jq)",
               "gh api repos/Jeon-Jonghyuk/InsuRo/pulls --jq '.[].number'", ALLOW),
        "X1": ("무토큰 머지는 반드시 차단",
               "gh api -X PUT repos/Jeon-Jonghyuk/InsuRo/pulls/256/merge "
               "-f merge_method=squash", DENY),
    }


@pytest.mark.parametrize("case_id", REDLINE_IDS)
def test_anu_normal_paths_are_not_blocked(case_id, tmp_path):
    """레드라인: 가드가 ANU 의 정상 운영 경로(머지/조회/PR생성)를 막지 않아야 한다."""
    keys = tmp_path / "env.keys"
    keys.write_text("BOT_GITHUB_TOKEN=ghs_dummy_for_test\n", encoding="utf-8")
    label, command, expected = _redline_cases(keys)[case_id]
    got = judge(command, env=_env(HOME=tmp_path))
    assert got.verdict == expected, (
        f"{case_id} {label}\ncmd={command!r}\n기대={expected} 실제={got.verdict}\n"
        f"사유={got.reason[:500]}"
    )


# --------------------------------------------------------------------------- 불변식
def test_hook_file_is_untouched_by_this_suite():
    """이 스위트는 훅을 '읽기만' 해야 한다 — 실행 중 본문이 바뀌면 테스트 자체가 오염이다."""
    assert _DIGEST_AT_IMPORT is not None, f"수집 시점에 가드가 없었다(회귀): {HOOK}"
    assert hashlib.md5(HOOK.read_bytes()).hexdigest() == _DIGEST_AT_IMPORT, (
        "테스트 실행 중 가드 본문이 변경됐다"
    )
