#!/usr/bin/env bash
# 봇 세션이 오너 PAT 로 GitHub 쓰기 작업을 하는 것을 차단한다.
#
# 배경(2026-08-20 실증):
#   봇이 `gh` 를 토큰 주입 없이 호출 → ~/.config/gh/hosts.yml 의 오너 개인 PAT 가 잡힘
#   → PR 작성자·merged_by 가 오너 이름으로 찍혀 **감사 기록이 오염**됨.
#
# 핵심(2026-08-21 ANU 실측): BOT_GITHUB_TOKEN 은 이미 존재하고 유효하다(ghs_, 자동 갱신).
#   "토큰이 없어서"가 아니라 "주입하지 않아서" 생긴 문제다. 그래서 경로 강제로 푼다.
#
# task-3017(2026-08-25): grep 기반 v1 은 쓰기형 25/44 케이스를 통과시켰다(하누만 실측).
#   원인 = 라인지향 grep + 전역 부분일치 + 무력화 스위치 2개(`<<`, 'gh-token-guard').
#   v2 는 bash 래퍼 + 내장 python3 파서로 교체한다:
#     · heredoc 본문만 제거(명령줄은 계속 분석) · 백슬래시 개행 연결 · 인용부호 인지 주석 제거
#     · `;`/`&&`/`||`/`|`/`&`/개행 기준 **문장 단위** 판정
#     · 메서드 플래그 4종 철자(-X V / -XV / --method V / --method=V, 대소문자·탭 무관)
#     · gh 서브커맨드 쓰기 동사 전수 + `gh api` 암묵적 POST(-f/-F/--field/--input)
#     · graphql 은 `mutation` 이 있을 때만 쓰기
#     · 토큰 판정 = **그 문장 자신의 선행 env 대입**(빈 값 불가). 주석/앞줄 잔상 무효
#     · 탈출구 = 문장 앞에 ALLOW_OWNER_PAT=1
#   python3 부재 / 파서 예외 = fail-closed(deny).
IN=$(cat)

# 입력이 비면 판정할 명령이 없다 → 통과(훅이 죽지 않게).
if [ -z "$IN" ]; then
  exit 0
fi

emit_fallback_deny() {
  printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"gh-token-guard: 판정기를 실행할 수 없어 fail-closed 로 차단했다.\n\n사유: '"$1"'\n\ngh 쓰기 작업은 반드시 봇 토큰을 주입해서 호출하라:\n  set -a; source /home/jay/workspace/.env.keys; set +a\n  GH_TOKEN=\"$BOT_GITHUB_TOKEN\" gh api -X PUT ...\n\n회장님 개인 PAT 를 의도적으로 써야 한다면 문장 앞에 ALLOW_OWNER_PAT=1 을 붙여라."},"systemMessage":"gh-token-guard fail-closed — 판정기 실행 불가"}'
}

if ! command -v python3 >/dev/null 2>&1; then
  emit_fallback_deny "python3 를 찾을 수 없다"
  exit 0
fi

GH_GUARD_INPUT="$IN" python3 - <<'PYEOF'
import json
import os
import re
import shlex
import sys

RAW = os.environ.get("GH_GUARD_INPUT", "")

GUIDE = """올바른 형태:
  set -a; source /home/jay/workspace/.env.keys; set +a
  GH_TOKEN="$BOT_GITHUB_TOKEN" gh api -X PUT ...
  GH_TOKEN="$BOT_GITHUB_TOKEN" gh pr merge 251 --squash

BOT_GITHUB_TOKEN 은 이미 존재하고 유효하다(ghs_, 자동 갱신). 주입만 하면 된다.
조회성 명령(gh pr view/list/checks, gh api GET, gh api graphql 조회)은 이 제한을 받지 않는다.

회장님 개인 PAT 를 의도적으로 써야 한다면(이 가드는 "의도치 않은 폴백"만 막는다)
해당 문장 앞에 ALLOW_OWNER_PAT=1 을 붙여라:
  ALLOW_OWNER_PAT=1 gh pr merge 251 --squash

주의: 토큰은 **그 문장 자신의 선행 env 대입**만 인정된다.
앞줄에서 한 번 주입했다고 뒷줄이 통과되지 않는다(env prefix 는 그 명령에만 적용되므로)."""


def emit(reason, sysmsg):
    out = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        },
        "systemMessage": sysmsg,
    }
    sys.stdout.write(json.dumps(out, ensure_ascii=False))
    sys.stdout.flush()
    sys.exit(0)


# ---------------------------------------------------------------- 어휘 정의
SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "ash"}
WRAPPERS = {"env", "nohup", "command", "time", "stdbuf", "setsid", "exec", "sudo", "doas"}

WRITE_METHODS = {"PUT", "POST", "PATCH", "DELETE"}
READ_METHODS = {"GET", "HEAD", "OPTIONS"}

# gh 서브커맨드 쓰기 동사(명사와 무관하게 동사 하나로 판정 — 누락보다 과탐이 안전)
WRITE_VERBS = {
    "create", "merge", "edit", "close", "reopen", "review", "comment", "delete",
    "upload", "ready", "set", "remove", "rename", "run", "cancel", "rerun",
    "sync", "lock", "unlock", "transfer", "archive", "unarchive", "add",
    "update", "enable", "disable", "fork", "approve", "pin", "unpin",
    "restore", "revoke", "unpublish", "publish", "deploy", "push", "commit",
}

# 값을 하나 소모하는 플래그(위치인자 계산에서 그 값을 건너뛰기 위한 목록)
VALUE_FLAGS = {
    "-X", "--method", "-f", "-F", "--field", "--raw-field", "-H", "--header",
    "--input", "--jq", "-q", "--template", "-t", "--title", "--hostname",
    "--cache", "-b", "--body", "-F", "--body-file", "-B", "--base", "--head",
    "-R", "--repo", "-a", "--assignee", "-l", "--label", "-m", "--milestone",
    "-p", "--project", "-r", "--reviewer", "--json", "--subject", "--notes",
    "--notes-file", "--tag", "--target", "-e", "--env", "-c", "--comment",
    "--search", "-L", "--limit", "-s", "--state", "--author", "--filename",
    "--ref", "--branch", "--key", "--visibility", "--org", "--user",
}

FIELD_FLAGS = {"-f", "-F", "--field", "--raw-field", "--input"}

MUTATION_RE = re.compile(r"(?<![A-Za-z0-9_])mutation(?![A-Za-z0-9_])")
ASSIGN_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", re.S)
VARREF_RE = re.compile(r"^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$")
SHELL_LINE_RE = re.compile(r"(?:^|[;&|(]\s*|\s)(?:/usr/bin/|/bin/|/usr/local/bin/)?(?:bash|sh|zsh|dash|ksh)\b")
HEREDOC_OP_RE = re.compile(r"<<-?[ \t]*([\"']?)([A-Za-z_][A-Za-z0-9_]*)\1")

TOKEN_VARS = ("GH_TOKEN", "GITHUB_TOKEN")
ESCAPE_VAR = "ALLOW_OWNER_PAT"


# ---------------------------------------------------------------- 전처리
def find_heredoc_delims(line):
    """한 줄에서 인용부호 밖의 heredoc 연산자만 찾는다. `<<<`(herestring)는 제외."""
    res = []
    i = 0
    n = len(line)
    sq = dq = False
    while i < n:
        c = line[i]
        if sq:
            i += 1
            if c == "'":
                sq = False
            continue
        if dq:
            if c == "\\":
                i += 2
                continue
            i += 1
            if c == '"':
                dq = False
            continue
        if c == "\\":
            i += 2
            continue
        if c == "'":
            sq = True
            i += 1
            continue
        if c == '"':
            dq = True
            i += 1
            continue
        if c == "<" and i + 1 < n and line[i + 1] == "<":
            j = i + 2
            if j < n and line[j] == "<":
                i = j + 1
                continue
            dash = False
            if j < n and line[j] == "-":
                dash = True
                j += 1
            while j < n and line[j] in " \t":
                j += 1
            quote = ""
            if j < n and line[j] in "'\"":
                quote = line[j]
                j += 1
            m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[j:])
            if m:
                delim = m.group(0)
                j += len(delim)
                if quote and j < n and line[j] == quote:
                    j += 1
                res.append((delim, dash))
            i = j
            continue
        i += 1
    return res


def extract_heredocs(text):
    """heredoc **본문만** 제거하고 명령줄은 남긴다.
    본문의 소유 명령이 셸(bash/sh/...)이면 그 본문은 별도 블록으로 추가 분석한다.
    종료 구분자를 못 찾으면 아무것도 소비하지 않는다(오탐 fail-safe)."""
    lines = text.split("\n")
    kept = []
    extra = []
    i = 0
    while i < len(lines):
        line = lines[i]
        i += 1
        delims = find_heredoc_delims(line)
        if delims:
            kept.append(HEREDOC_OP_RE.sub(" ", line))
        else:
            kept.append(line)
        for delim, dash in delims:
            body = []
            j = i
            found = False
            while j < len(lines):
                cand = lines[j].strip() if dash else lines[j].rstrip()
                if cand == delim:
                    found = True
                    j += 1
                    break
                body.append(lines[j])
                j += 1
            if found:
                i = j
                if SHELL_LINE_RE.search(line):
                    extra.append("\n".join(body))
    return "\n".join(kept), extra


def strip_comments(text):
    """인용부호(줄 넘김 포함)를 인지하는 `#` 주석 제거."""
    out = []
    i = 0
    n = len(text)
    sq = dq = False
    while i < n:
        c = text[i]
        if sq:
            out.append(c)
            i += 1
            if c == "'":
                sq = False
            continue
        if dq:
            if c == "\\" and i + 1 < n:
                out.append(c)
                out.append(text[i + 1])
                i += 2
                continue
            out.append(c)
            i += 1
            if c == '"':
                dq = False
            continue
        if c == "\\" and i + 1 < n:
            out.append(c)
            out.append(text[i + 1])
            i += 2
            continue
        if c == "'":
            sq = True
            out.append(c)
            i += 1
            continue
        if c == '"':
            dq = True
            out.append(c)
            i += 1
            continue
        if c == "#" and (not out or out[-1] in " \t\n"):
            while i < n and text[i] != "\n":
                i += 1
            continue
        out.append(c)
        i += 1
    return "".join(out)


def join_continuations(text):
    return re.sub(r"\\\n", " ", text)


def split_statements(text):
    """`;` `&&` `||` `|` `|&` `&` 개행 기준 문장 분할(인용부호 인지).
    `2>&1` 같은 리다이렉션의 `&` 는 분할하지 않는다."""
    stmts = []
    cur = []
    i = 0
    n = len(text)
    sq = dq = False
    while i < n:
        c = text[i]
        if sq:
            cur.append(c)
            i += 1
            if c == "'":
                sq = False
            continue
        if dq:
            if c == "\\" and i + 1 < n:
                cur.append(c)
                cur.append(text[i + 1])
                i += 2
                continue
            cur.append(c)
            i += 1
            if c == '"':
                dq = False
            continue
        if c == "\\" and i + 1 < n:
            cur.append(c)
            cur.append(text[i + 1])
            i += 2
            continue
        if c == "'":
            sq = True
            cur.append(c)
            i += 1
            continue
        if c == '"':
            dq = True
            cur.append(c)
            i += 1
            continue
        if c in ";\n":
            stmts.append("".join(cur))
            cur = []
            i += 1
            continue
        if c == "&":
            if i + 1 < n and text[i + 1] == "&":
                stmts.append("".join(cur))
                cur = []
                i += 2
                continue
            prev = "".join(cur).rstrip()
            if prev.endswith(">") or prev.endswith("<"):
                cur.append(c)
                i += 1
                continue
            stmts.append("".join(cur))
            cur = []
            i += 1
            continue
        if c == "|":
            if i + 1 < n and text[i + 1] in "|&":
                stmts.append("".join(cur))
                cur = []
                i += 2
                continue
            stmts.append("".join(cur))
            cur = []
            i += 1
            continue
        cur.append(c)
        i += 1
    stmts.append("".join(cur))
    return stmts


def clean_stmt(s):
    s = s.strip()
    while s and s[0] in "({":
        s = s[1:].strip()
    while s and s[-1] in ")}":
        s = s[:-1].rstrip()
    return s.strip()


def tokenize(s):
    try:
        return shlex.split(s, comments=False, posix=True)
    except ValueError:
        pass
    try:
        lex = shlex.shlex(s, posix=True)
        lex.whitespace_split = True
        lex.commenters = ""
        return list(lex)
    except Exception:
        return [t.strip("'\"") for t in re.findall(r"\S+", s)]


# ---------------------------------------------------------------- 판정
def extract_method(args):
    i = 0
    while i < len(args):
        a = args[i]
        if a in ("-X", "--method"):
            if i + 1 < len(args):
                return args[i + 1].strip().upper()
            return None
        if a.startswith("--method="):
            return a.split("=", 1)[1].strip().upper()
        if a.startswith("-X=") and len(a) > 3:
            return a.split("=", 1)[1].strip().upper()
        if a.startswith("-X") and len(a) > 2:
            return a[2:].strip().upper()
        i += 1
    return None


def positional_args(args):
    out = []
    i = 0
    while i < len(args):
        a = args[i]
        if len(a) > 1 and a.startswith("-"):
            if "=" in a:
                i += 1
                continue
            if a in VALUE_FLAGS:
                i += 2
                continue
            i += 1
            continue
        out.append(a)
        i += 1
    return out


def has_field_flag(args):
    for a in args:
        if a in FIELD_FLAGS:
            return True
        if a.startswith("--field=") or a.startswith("--raw-field=") or a.startswith("--input="):
            return True
        if len(a) > 2 and (a.startswith("-f") or a.startswith("-F")) and "=" in a:
            return True
    return False


def classify_gh(args):
    """(is_write, reason) 반환."""
    method = extract_method(args)
    pos = positional_args(args)
    noun = pos[0] if pos else ""
    verb = pos[1] if len(pos) > 1 else ""

    if noun == "api":
        target = ""
        for p in pos[1:]:
            target = p
            break
        is_graphql = target == "graphql" or target.endswith("/graphql")
        if is_graphql:
            if any(MUTATION_RE.search(a) for a in args):
                return True, "gh api graphql + mutation (쓰기)"
            return False, ""
        if method:
            if method in WRITE_METHODS:
                return True, "HTTP 메서드 %s (쓰기)" % method
            if method in READ_METHODS:
                return False, ""
            return True, "알 수 없는 HTTP 메서드 %s — fail-closed" % method
        if has_field_flag(args):
            return True, "gh api 에 -f/-F/--field/--input 이 있고 메서드 미지정 → gh 는 POST 로 보낸다 (쓰기)"
        return False, ""

    if method and method in WRITE_METHODS:
        return True, "HTTP 메서드 %s (쓰기)" % method
    if verb and verb in WRITE_VERBS:
        return True, "gh %s %s (쓰기 서브커맨드)" % (noun, verb)
    if noun and noun in WRITE_VERBS and not verb:
        return True, "gh %s (쓰기 서브커맨드)" % noun
    return False, ""


def token_ok(env):
    for key in TOKEN_VARS:
        if key in env and env[key].strip() != "":
            return True
    return False


def escape_ok(env):
    return ESCAPE_VAR in env and env[ESCAPE_VAR].strip() != ""


findings = []


def analyze_block(text, inherited_env, varmap, depth):
    if depth > 6:
        return
    main_text, extra_blocks = extract_heredocs(text)
    main_text = strip_comments(main_text)
    main_text = join_continuations(main_text)
    stmts = split_statements(main_text)

    block_env = dict(inherited_env)

    for raw_stmt in stmts:
        s = clean_stmt(raw_stmt)
        if not s:
            continue
        toks = tokenize(s)
        if not toks:
            continue

        env = dict(block_env)
        idx = 0
        recursed = False

        while idx < len(toks):
            t = toks[idx]
            m = ASSIGN_RE.match(t)
            if m:
                env[m.group(1)] = m.group(2)
                varmap[m.group(1)] = m.group(2)
                idx += 1
                continue
            base = t.split("/")[-1]
            if base == "export":
                idx += 1
                while idx < len(toks):
                    m2 = ASSIGN_RE.match(toks[idx])
                    if not m2:
                        break
                    # export 는 같은 셸의 이후 문장에도 유효하다
                    env[m2.group(1)] = m2.group(2)
                    block_env[m2.group(1)] = m2.group(2)
                    varmap[m2.group(1)] = m2.group(2)
                    idx += 1
                continue
            if base == "timeout":
                idx += 1
                while idx < len(toks) and toks[idx].startswith("-"):
                    idx += 1
                if idx < len(toks):
                    idx += 1
                continue
            if base == "xargs":
                idx += 1
                while idx < len(toks) and len(toks[idx]) > 1 and toks[idx].startswith("-"):
                    if toks[idx] in ("-I", "-n", "-P", "-d", "-a", "-E", "-s", "-L"):
                        idx += 2
                    else:
                        idx += 1
                continue
            if base in WRAPPERS:
                idx += 1
                continue
            if base in SHELLS:
                j = idx + 1
                cflag = None
                while j < len(toks):
                    tt = toks[j]
                    if tt.startswith("--"):
                        j += 1
                        continue
                    if tt.startswith("-") and "c" in tt[1:]:
                        cflag = j
                        break
                    if not tt.startswith("-"):
                        break
                    j += 1
                if cflag is not None and cflag + 1 < len(toks):
                    analyze_block(toks[cflag + 1], env, dict(varmap), depth + 1)
                recursed = True
                break
            break

        if recursed or idx >= len(toks):
            continue

        cmdword = toks[idx]
        mv = VARREF_RE.match(cmdword)
        if mv and mv.group(1) in varmap:
            cmdword = varmap[mv.group(1)].strip("'\"")
        base = cmdword.split("/")[-1]
        if base != "gh":
            continue

        args = toks[idx + 1:]
        is_write, why = classify_gh(args)
        if not is_write:
            continue
        if escape_ok(env):
            continue
        if token_ok(env):
            continue

        shown = " ".join(s.split())
        if len(shown) > 200:
            shown = shown[:200] + " ..."
        findings.append((shown, why))

    for blk in extra_blocks:
        analyze_block(blk, dict(block_env), dict(varmap), depth + 1)


def main():
    raw = RAW
    cmd = ""
    try:
        data = json.loads(raw)
        if isinstance(data, dict):
            ti = data.get("tool_input") or {}
            if isinstance(ti, dict):
                cmd = ti.get("command") or ""
            elif isinstance(ti, str):
                cmd = ti
            if not cmd:
                cmd = data.get("command") or ""
        if not isinstance(cmd, str):
            cmd = str(cmd)
    except Exception:
        # JSON 이 깨졌으면 원문 전체를 명령으로 간주해 분석한다(fail-closed 방향).
        cmd = raw

    if not cmd.strip():
        sys.exit(0)

    analyze_block(cmd, {}, {}, 0)

    if not findings:
        sys.exit(0)

    lines = []
    lines.append("GitHub 쓰기 작업에 봇 토큰이 주입되지 않았다.")
    lines.append("")
    lines.append("토큰 없이 gh 를 호출하면 ~/.config/gh/hosts.yml 의 **회장님 개인 PAT** 가 사용된다.")
    lines.append("그러면 PR 작성자·merged_by 가 회장님 이름으로 찍혀 **감사 기록이 오염**되고,")
    lines.append("누가 무엇을 했는지 사후 추적이 불가능해진다(2026-08-20 실제 사고).")
    lines.append("")
    lines.append("차단된 문장 %d 건:" % len(findings))
    for n, (shown, why) in enumerate(findings, 1):
        lines.append("  %d) %s" % (n, shown))
        lines.append("     감지: %s | GH_TOKEN/GITHUB_TOKEN 미주입(또는 빈 값)" % why)
    lines.append("")
    lines.append(GUIDE)

    emit("\n".join(lines), "gh 쓰기 작업에 봇 토큰 미주입 — 차단됨 (%d 문장)" % len(findings))


try:
    main()
except SystemExit:
    raise
except Exception as exc:  # fail-closed
    emit(
        "gh-token-guard 판정기가 예외로 중단되어 fail-closed 로 차단했다.\n"
        "예외: %s: %s\n\n%s" % (type(exc).__name__, exc, GUIDE),
        "gh-token-guard fail-closed — 파서 예외",
    )
PYEOF

rc=$?
if [ "$rc" -ne 0 ]; then
  emit_fallback_deny "판정기(python3)가 종료코드 $rc 로 실패했다"
fi
exit 0
