#!/usr/bin/env python3
"""Decision core for agent-guard.sh (see that file for the hook contract).

Reads the PreToolUse payload JSON on stdin and prints exactly one decision:
  BLOCK_ATTRIB | BLOCK_FORCE | OK

Never executes the command. shlex.split only tokenizes shell-like text; a
`$(...)` in the command is a literal token, never a subshell. Any error prints
OK (fail-open) so a guard bug cannot break a legitimate tool call.
"""
import json
import os
import re
import shlex
import sys

# --- Rule data (extend here; this is the hookify-style rule surface) ----------
PROTECTED = {"main", "master", "develop"}
REMOTES = {"origin", "upstream"}
ATTRIBUTION = re.compile(
    r"co-authored-by:\s*(claude|anthropic|opus|sonnet|haiku|fable)"
    r"|noreply@anthropic\.com"
    r"|generated with\s+.*claude"
    r"|\U0001F916\s*generated"
    r"|claude code <",
    re.IGNORECASE,
)
# Force flags, and ONLY force flags.
#
# The previous pattern ended in `-\S*f\S*`, which matches any flag containing an
# `f` anywhere - so it fired on `--ff-only`, `--follow-tags`, and `--file=...`.
# Two real false positives followed:
#
#   git pull --ff-only && git push origin main   -> BLOCKED
#   git push --follow-tags origin main           -> BLOCKED
#
# Neither rewrites history. `--follow-tags` is the normal way to push a release
# tag with its commit, so the guard blocked a routine release step. Being
# fail-closed is right for a data-loss guard, but only against the thing it
# guards; blocking safe commands trains people to work around the hook, which
# costs more safety than it buys.
#
# The short-flag arm is now `-[A-Za-z]*f[A-Za-z]*`: a SINGLE dash followed only by
# letters, so it still catches `-f`, `-fq`, `-qf`, and never a `--long-flag`
# (whose second character is `-`, not a letter).
FORCE = re.compile(
    r"(^|\s)("
    r"--force(-with-lease|-if-includes)?(=\S+)?"  # --force, --force-with-lease[=ref], --force-if-includes
    r"|-[A-Za-z]*f[A-Za-z]*"  # -f and short clusters containing f
    r")(\s|$)"
)


# Shell separators that end one command and start the next. Used to attribute a
# flag to the command it actually belongs to.
SEPARATORS = re.compile(r"&&|\|\||;|\||\n")


def push_segments(cmd: str) -> list:
    """The sub-commands that invoke `git push`.

    A flag must be attributed to its own command. Scanning the whole string made
    `git commit -f ... && git push origin main` read as a force-push, because the
    `-f` belonged to the commit. Splitting on shell separators first keeps each
    command's flags with that command.

    Deliberately coarse: a separator inside a quoted string splits too, which can
    only ever produce MORE segments to inspect, never fewer - the safe direction
    for a guard.
    """
    return [seg for seg in SEPARATORS.split(cmd) if re.search(r"git\s+push", seg.lower())]


def decide(cmd: str) -> str:
    low = cmd.lower()

    # Rule 1: AI attribution inside a git commit.
    if re.search(r"git\s+commit", low) and ATTRIBUTION.search(cmd):
        return "BLOCK_ATTRIB"

    # Rule 2: force-push to a protected branch. Evaluate each push segment on its
    # own so a neighbouring command's flags cannot implicate it.
    for segment in push_segments(cmd):
        verdict = _decide_push(segment)
        if verdict != "OK":
            return verdict

    return "OK"


def _decide_push(cmd: str) -> str:
    """Verdict for a single `git push ...` command."""
    if FORCE.search(cmd):
        try:
            toks = shlex.split(cmd)
        except Exception:
            # A force-push we can't tokenize -> can't prove it's safe -> block.
            return "BLOCK_FORCE"
        if any(t in PROTECTED or t.split(":")[-1] in PROTECTED for t in toks):
            return "BLOCK_FORCE"
        # non-flag args after `push`; only a remote (or nothing) means a bare push
        args, seen = [], False
        for t in toks:
            if t == "push":
                seen = True
                continue
            if seen and not t.startswith("-"):
                args.append(t)
        nonremote = [a for a in args if a not in REMOTES]
        if not nonremote:
            # Bare force-push: the target is the current branch. Block if it's
            # protected OR undeterminable (fail-closed for a data-loss guard).
            cur = os.environ.get("CUR_BRANCH", "")
            if cur in PROTECTED or cur == "":
                return "BLOCK_FORCE"

    return "OK"


def main() -> None:
    try:
        data = json.load(sys.stdin)
        cmd = (data.get("tool_input") or {}).get("command", "")
        if not isinstance(cmd, str) or not cmd:
            print("OK")
            return
        print(decide(cmd))
    except Exception:
        print("OK")


if __name__ == "__main__":
    main()
