#!/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|$)"
)


# A refspec whose destination starts with `+` is a force-push without any flag:
# `git push origin +main`, `git push origin HEAD:+main`.
FORCED_REFSPEC = re.compile(r"(^|\s|:)\+\S")

# git's own options, which sit between `git` and the subcommand:
# `git -C /tmp/wt commit`, `git -c user.name=x commit`, `git --no-pager push`.
GIT_VALUE_OPTIONS = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"}
GIT_FLAG_OPTIONS = {
    "--no-pager",
    "--paginate",
    "-p",
    "-P",
    "--bare",
    "--literal-pathspecs",
    "--glob-pathspecs",
    "--noglob-pathspecs",
    "--icase-pathspecs",
    "--no-replace-objects",
    "--no-optional-locks",
}


# 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 tokenize(cmd: str) -> list:
    """shlex tokens, or whitespace tokens when shlex cannot parse (an unbalanced
    quote). Never returns None: a guard has to reason about a segment it cannot
    parse cleanly, not skip it."""
    try:
        return shlex.split(cmd)
    except Exception:
        return cmd.split()


def git_subcommand(toks: list) -> str:
    """The git subcommand in a token list, skipping git's global options.

    `git -C <dir> commit` and `git -c k=v push` are the same commands as
    `git commit` and `git push`; matching `git\s+commit` saw neither.
    Returns "" when the tokens do not invoke git.
    """
    for i, t in enumerate(toks):
        if os.path.basename(t).lower() != "git":
            continue
        j = i + 1
        while j < len(toks):
            opt = toks[j]
            if opt in GIT_VALUE_OPTIONS:
                j += 2
            elif opt.startswith("-"):
                j += 1
            else:
                return opt.lower()
        return ""
    return ""


def segments(cmd: str) -> list:
    """The sub-commands of a shell line, paired with their tokens.

    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, tokenize(seg)) for seg in SEPARATORS.split(cmd)]


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

    # Rule 1: AI attribution inside a git commit.
    commits = re.search(r"git\s+commit", low) or any(
        git_subcommand(toks) == "commit" for _, toks in segs
    )
    if commits 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 seg, toks in segs:
        if git_subcommand(toks) != "push" and not re.search(r"git\s+push", seg.lower()):
            continue
        verdict = _decide_push(seg)
        if verdict != "OK":
            return verdict

    return "OK"


def _ref_target(arg: str) -> tuple:
    """(forced, branch) for one push argument.

    The destination is the part after the last `:`; a leading `+` on it is a
    force-push; `refs/heads/main` names the same branch as `main`.
    """
    dst = arg.rsplit(":", 1)[-1]
    forced = dst.startswith("+") or arg.startswith("+")
    name = dst.lstrip("+")
    if name.startswith("refs/heads/"):
        name = name[len("refs/heads/"):]
    return forced, name


def _decide_push(cmd: str) -> str:
    """Verdict for a single `git push ...` command."""
    force_flag = bool(FORCE.search(cmd))
    if not force_flag and not FORCED_REFSPEC.search(cmd):
        return "OK"
    try:
        toks = shlex.split(cmd)
    except Exception:
        # A force-push we can't tokenize -> can't prove it's safe -> block.
        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)
    targets = [_ref_target(a) for a in args]
    if any(forced and name in PROTECTED for forced, name in targets):
        return "BLOCK_FORCE"
    if force_flag:
        if any(name in PROTECTED for _, name in targets):
            return "BLOCK_FORCE"
        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()
