#!/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 | BLOCK_UNATTENDED<TAB>reason | BLOCK_PARSE | OK

Never executes the command. shlex.split only tokenizes shell-like text; a
`$(...)` in the command is a literal token, never a subshell. The processes it
does start are read-only `git rev-parse` / `git diff --name-only` calls on a
path, with argv and no shell.

Attended (MULTI_AGENT_UNATTENDED unset): the attribution and force-push rules
only, and any error prints OK (fail-open) so a guard bug cannot break a
legitimate tool call. Unattended: unattended_policy.py is consulted as well,
and an error prints BLOCK_PARSE (fail-closed).
"""
import json
import os
import re
import shlex
import signal
import sys
import unicodedata

# An internal deadline well under the hook timeout: if the guard cannot decide
# in this long, it prints the blocking verdict when unattended (fail-closed) and
# the allow verdict when attended (a guard bug must not break legitimate work).
# MA_GUARD_DEADLINE_SECONDS may lower it, never raise it: a longer deadline
# would let the hook timeout decide instead of the guard.
DEADLINE_SECONDS = 5


def deadline_seconds(env=None) -> float:
    env = os.environ if env is None else env
    try:
        v = float(env.get("MA_GUARD_DEADLINE_SECONDS", ""))
    except ValueError:
        return DEADLINE_SECONDS
    return v if 0 < v < DEADLINE_SECONDS else DEADLINE_SECONDS

# --- 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,
)


def _strip_set(ranges):
    out = set()
    for spec in ranges:
        first, _, last = str(spec).partition("-")
        try:
            lo, hi = int(first, 16), int(last or first, 16)
        except ValueError:
            continue
        out.update(chr(cp) for cp in range(lo, hi + 1))
    return out


def _load_confusables():
    here = os.path.dirname(os.path.abspath(__file__))
    try:
        with open(os.path.join(here, "..", "lib", "confusables.json"), encoding="utf-8") as fh:
            table = json.load(fh)
        return _strip_set(table.get("stripRanges", [])), dict(table.get("map", {}))
    except (OSError, ValueError):
        return set(), {}


_STRIP, _MAP = _load_confusables()


def normalize_for_match(text: str) -> str:
    """NFKC, Default_Ignorable_Code_Point removed, look-alikes mapped to ASCII.

    The same fold lib/normalize-text.mjs applies, from the same table. Rules
    that match text content run on the raw command and on this copy, so a
    trailer written with a zero-width space or a Cyrillic letter still matches.
    The table's map also carries the compatibility letters newer than this
    Python's unicodedata, which NFKC here would leave alone. Without the table
    only NFKC applies.
    """
    return "".join(
        _MAP.get(ch, ch) for ch in unicodedata.normalize("NFKC", text) if ch not in _STRIP
    )


# 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")


# shlex grows each token one character at a time, which is quadratic in the
# token's length: a megabyte argument takes longer than the deadline. A segment
# longer than this is split on whitespace with its quotes and backslashes
# removed instead, which keeps every word the segment names in order.
SHLEX_MAX_CHARS = 16384


def coarse_tokens(cmd: str) -> list:
    return [re.sub(r"[\"'\\]", "", t) for t in cmd.split()]


def tokenize(cmd: str) -> list:
    """shlex tokens, or whitespace tokens when shlex cannot parse (an unbalanced
    quote) or the segment is too long for it. Never returns None: a guard has to
    reason about a segment it cannot parse cleanly, not skip it."""
    if len(cmd) > SHLEX_MAX_CHARS:
        return coarse_tokens(cmd)
    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)]


# `git push` options that take the NEXT token as their value. Without this list
# `git push -f -o ci.skip origin` reads `ci.skip` as the repository and
# `origin` as a branch name, and the bare-push check never runs.
PUSH_VALUE_OPTIONS = {"-o", "--push-option", "--repo", "--receive-pack", "--exec"}
# Pushes that rewrite or remove every ref, protected ones included.
PUSH_ALL_REFS = {"--all", "--mirror", "--branches"}
PUSH_DELETE = {"-d", "--delete", "--prune"}
# Pushes that can remove or rewrite a protected ref with no force flag: a
# mirror (force + prune over every ref), a prune, a delete, and a refspec with
# an empty source (`:main`), which deletes its destination.
PUSH_GATE = re.compile(r"(^|\s)(-d|--delete|--mirror|--prune)(\s|=|$)|(^|\s)\+?:\S")
# A destination that names the branch the checkout is on.
CURRENT_BRANCH_REFS = {"HEAD", "@"}
SHORT_FORCE = re.compile(r"^-[A-Za-z]*f[A-Za-z]*$")


SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
MAX_DEPTH = 3


def flatten(cmd: str) -> str:
    """cmd with every unquoted `(`, `)` and backtick turned into a separator,
    so a command inside a subshell or a substitution is a segment of its own.
    Like the separator split, this can only add segments."""
    out, q, i = [], None, 0
    while i < len(cmd):
        c = cmd[i]
        if q == "'":
            q = None if c == "'" else q
        elif c == "\\" and i + 1 < len(cmd):
            out.append(cmd[i : i + 2])
            i += 2
            continue
        elif q == '"':
            q = None if c == '"' else q
        elif c in ("'", '"'):
            q = c
        elif c in "()`":
            out.append(" ; ")
            i += 1
            continue
        out.append(c)
        i += 1
    return "".join(out)


def _nested_payload(toks: list):
    """The command line a segment hands to another shell: the `-c` argument
    of sh/bash/zsh (after any env assignments or wrappers), or the words of
    `eval`. None when the segment runs no nested line."""
    i = 0
    while i < len(toks) and (re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[i]) or os.path.basename(toks[i]) in ("env", "nohup", "command", "builtin", "exec", "nice", "time")):
        i += 1
    if i >= len(toks):
        return None
    base = os.path.basename(toks[i]).lower()
    if base == "eval":
        return " ".join(toks[i + 1 :])
    if base not in SHELLS:
        return None
    has_c = False
    for t in toks[i + 1 :]:
        if t.startswith("-") and not t.startswith("--"):
            has_c = has_c or "c" in t[1:]
            continue
        if t.startswith("--"):
            continue
        return t if has_c else None
    return None


def decide(cmd: str, depth: int = 0) -> str:
    low = cmd.lower()
    segs = segments(flatten(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) or ATTRIBUTION.search(normalize_for_match(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. A `cd` earlier
    # on the line moves the push into another checkout, whose branch is the one
    # that counts.
    cwd = None
    for seg, toks in segs:
        if toks and toks[0] in ("{", "}"):
            toks = toks[1:]
        if toks and toks[0] in ("cd", "pushd"):
            cwd = _cd_target(toks, cwd)
            continue
        payload = _nested_payload(toks)
        if payload and depth < MAX_DEPTH:
            verdict = decide(payload, depth + 1)
            if verdict != "OK":
                return verdict
            continue
        if git_subcommand(toks) != "push" and not re.search(r"git\s+push", seg.lower()):
            continue
        verdict = _decide_push(seg, toks, cwd)
        if verdict != "OK":
            return verdict

    return "OK"


def _cd_target(toks: list, cwd):
    """The directory a `cd` moves to, or "" when it cannot be read literally
    (`cd`, `cd -`, `cd ~`, `cd "$X"`): a push after it is on an unknown branch.
    Once unknown, a relative cd stays unknown; only an absolute one resolves."""
    args = [t for t in toks[1:] if not t.startswith("-")]
    if not args or any(ch in args[0] for ch in "$`~*?"):
        return ""
    if os.path.isabs(args[0]):
        return os.path.normpath(args[0])
    if cwd == "":
        return ""
    base = cwd if cwd else os.getcwd()
    return os.path.normpath(os.path.join(base, args[0]))


def _branch_of(directory: str) -> str:
    """The branch checked out in `directory`, read with the same read-only
    `git rev-parse` the wrapper runs for the cwd. "" when it cannot be read."""
    if not directory:
        return ""
    try:
        import subprocess

        out = subprocess.run(
            ["git", "-C", directory, "rev-parse", "--abbrev-ref", "HEAD"],
            capture_output=True,
            text=True,
            timeout=3,
        )
        return out.stdout.strip() if out.returncode == 0 else ""
    except Exception:
        return ""


def _parse_push(toks: list):
    """(git_dir, options, positionals) for a tokenized `git ... push ...`.

    git_dir is the directory named by `-C` / `--work-tree` / `--git-dir`, or
    None. Values of options that take one are consumed, never read as a
    repository or refspec.
    """
    git_dir, i = None, 0
    while i < len(toks) and os.path.basename(toks[i]).lower() != "git":
        i += 1
    i += 1
    while i < len(toks):
        t = toks[i]
        if t in ("-C", "--work-tree", "--git-dir"):
            if i + 1 < len(toks):
                val = toks[i + 1]
                if t == "--git-dir":
                    val = os.path.dirname(val.rstrip("/")) or "."
                git_dir = val if git_dir is None or os.path.isabs(val) else os.path.join(git_dir, val)
            i += 2
            continue
        if t.startswith("--work-tree=") or t.startswith("--git-dir="):
            key, _, val = t.partition("=")
            if key == "--git-dir":
                val = os.path.dirname(val.rstrip("/")) or "."
            git_dir = val
            i += 1
            continue
        if t in GIT_VALUE_OPTIONS:
            i += 2
            continue
        if t.startswith("-"):
            i += 1
            continue
        break
    # toks[i] is the subcommand
    opts, positionals = [], []
    j = i + 1
    while j < len(toks):
        t = toks[j]
        if t in PUSH_VALUE_OPTIONS:
            opts.append((t, toks[j + 1] if j + 1 < len(toks) else ""))
            j += 2
            continue
        if t.startswith("-"):
            key, _, val = t.partition("=")
            opts.append((key, val))
        else:
            positionals.append(t)
        j += 1
    return git_dir, opts, positionals


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

    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`. A refspec
    with no `:` pushes to the branch of the same name.
    """
    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, toks=None, cwd=None) -> str:
    """Verdict for a single `git push ...` command.

    `git push [options] [<repository> [<refspec>...]]`: the first positional is
    always the repository, whatever it is called; everything after it is a
    refspec. A refspec of HEAD or @, or none at all, targets the branch that is
    checked out in the directory the push runs in.
    """
    if toks is None:
        toks = tokenize(cmd)
    force_flag = bool(FORCE.search(cmd))
    if not force_flag and not FORCED_REFSPEC.search(cmd) and not PUSH_GATE.search(cmd):
        return "OK"
    if len(cmd) <= SHLEX_MAX_CHARS:
        try:
            shlex.split(cmd)
        except Exception:
            # A force-push we can't tokenize -> can't prove it's safe -> block.
            return "BLOCK_FORCE"
    git_dir, opts, positionals = _parse_push(toks)
    keys = {k for k, _ in opts}
    force_flag = any(
        k in ("--force", "--force-with-lease", "--force-if-includes") or SHORT_FORCE.match(k) for k in keys
    )
    repo_opt = any(k == "--repo" for k in keys)
    refspecs = positionals if repo_opt else positionals[1:]
    if "--mirror" in keys:
        return "BLOCK_FORCE"
    deleting = bool(keys & PUSH_DELETE) or any(r.lstrip("+").startswith(":") for r in refspecs)

    if git_dir is not None:
        base = cwd if cwd else os.getcwd()
        current = _branch_of(git_dir if os.path.isabs(git_dir) else os.path.join(base, git_dir))
    elif cwd is not None:
        current = _branch_of(cwd)
    else:
        current = os.environ.get("CUR_BRANCH", "")

    targets = []
    for ref in refspecs:
        forced, name = _ref_target(ref)
        if name in CURRENT_BRANCH_REFS:
            name = current or "HEAD"
        targets.append((forced, name))

    if any(forced and name in PROTECTED for forced, name in targets):
        return "BLOCK_FORCE"
    if not (force_flag or deleting):
        return "OK"
    if keys & PUSH_ALL_REFS:
        return "BLOCK_FORCE"
    if any(name in PROTECTED for _, name in targets):
        return "BLOCK_FORCE"
    if "--prune" in keys and (not targets or any("*" in name for _, name in targets)):
        return "BLOCK_FORCE"
    unresolved = any(name == "HEAD" for _, name in targets)
    if force_flag and (not targets or unresolved):
        # The target is the current branch. Block if it's protected OR
        # undeterminable (fail-closed for a data-loss guard).
        if current in PROTECTED or current in ("", "HEAD"):
            return "BLOCK_FORCE"
    return "OK"


FALLBACK_SPLIT = re.compile(r"&&|\|\||[;|\n()`]|\\n")
FALLBACK_FORCE_WORDS = {"-d", "--force", "--force-with-lease", "--force-if-includes", "--mirror", "--delete", "--prune"}
FALLBACK_TARGET_WORDS = {"head", "@", "--all", "--mirror", "--branches"}


def _fallback_forced(tok: str) -> bool:
    key = tok.split("=", 1)[0]
    if key in FALLBACK_FORCE_WORDS or tok.startswith("+") or ":+" in tok or tok.startswith(":"):
        return True
    return tok.startswith("-") and not tok.startswith("--") and tok[1:].isalpha() and "f" in tok


def _fallback_protected(tok: str) -> bool:
    name = tok.rsplit(":", 1)[-1].lstrip("+")
    if name.startswith("refs/heads/"):
        name = name[len("refs/heads/"):]
    return name in PROTECTED or tok in FALLBACK_TARGET_WORDS


def fallback_verdict(text: str) -> str:
    """The always-on rules as a linear scan, for input the tokenizer could not
    finish in time. Each piece between shell separators is split on whitespace
    with its quotes and backslashes removed, and no regex that can backtrack
    runs over it. A push piece with a force indicator is blocked when it names a
    protected branch, HEAD / @ or every ref, or when the checkout's own branch
    is protected or unknown; a line with a git commit is blocked when the
    attribution pattern matches the raw or the folded text. Stricter than
    decide() by design: it only runs when decide() could not."""
    try:
        folded = normalize_for_match(text)
    except Exception:
        folded = text
    current = os.environ.get("CUR_BRANCH", "")
    commits = False
    for t in (text, folded):
        for piece in FALLBACK_SPLIT.split(t.lower()):
            toks = coarse_tokens(piece)
            if not any(os.path.basename(x) == "git" for x in toks):
                continue
            words = set(toks)
            if "commit" in words:
                commits = True
            if "push" not in words or not any(_fallback_forced(x) for x in toks):
                continue
            if any(_fallback_protected(x) for x in toks) or current in PROTECTED or current in ("", "HEAD"):
                return "BLOCK_FORCE"
    if commits and (ATTRIBUTION.search(text) or ATTRIBUTION.search(folded)):
        return "BLOCK_ATTRIB"
    return "OK"


def unattended() -> bool:
    """MULTI_AGENT_UNATTENDED=1 in the hook's own environment - the launcher's,
    never anything the run wrote (lib/unattended.sh ma_unattended)."""
    return os.environ.get("MULTI_AGENT_UNATTENDED", "") == "1"


def _arm_deadline(on_deadline) -> None:
    """Run on_deadline (which must end the process) once deadline_seconds()
    have passed. Where SIGALRM is unavailable the call simply runs to the end."""

    def _fire(_signum, _frame):
        try:
            signal.setitimer(signal.ITIMER_REAL, 0)
        except (ValueError, AttributeError, OSError):
            pass
        on_deadline()

    try:
        signal.signal(signal.SIGALRM, _fire)
        signal.setitimer(signal.ITIMER_REAL, deadline_seconds())
    except (ValueError, AttributeError, OSError):
        pass


def _exit_with(line: str) -> None:
    # os._exit skips the stdio flush, so the line is flushed first or the
    # caller reads nothing.
    if line:
        print(line, flush=True)
    os._exit(0)


def git_subcommand_mode() -> None:
    """`agent-guard.py --git-subcommand`: read a Bash payload (or a raw command
    on argv) and print each git subcommand it runs, one per line, using the same
    tokenizer the guard uses. pre-commit-check.sh reads this to detect a commit
    without a regex. Never executes anything. At the deadline it prints
    `commit`, so the caller scans rather than skipping a commit it could not
    rule out."""
    _arm_deadline(lambda: _exit_with("commit"))
    if len(sys.argv) > 2:
        cmd = sys.argv[2]
    else:
        try:
            data = json.load(sys.stdin)
            cmd = (data.get("tool_input") or {}).get("command", "")
        except Exception:
            cmd = ""
    if not isinstance(cmd, str) or not cmd:
        return
    try:
        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
        import unattended_policy

        for sub in unattended_policy.git_subcommands(cmd):
            print(sub)
    except Exception:
        # Fall back to the git subcommand of the whole token list; a caller that
        # gets nothing scans anyway, so silence here only costs an extra scan.
        subs = git_subcommand(tokenize(cmd))
        if subs:
            print(subs)


def commit_scope_mode() -> None:
    """`agent-guard.py --commit-scope`: read a Bash payload and print what its
    commit takes beyond the index (unattended_policy.git_commit_scope), one
    item per line. Never executes anything. At the deadline or on an error it
    prints `ADD_ALL` and `COMMIT_ALL`, so the caller scans the widest set rather
    than missing a path the commit could include."""
    widest = "ADD_ALL\nCOMMIT_ALL"
    _arm_deadline(lambda: _exit_with(widest))
    try:
        data = json.load(sys.stdin)
        cmd = (data.get("tool_input") or {}).get("command", "")
    except Exception:
        cmd = ""
    if not isinstance(cmd, str) or not cmd:
        return
    try:
        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
        import unattended_policy

        items = unattended_policy.git_commit_scope(cmd)
    except Exception:
        print(widest)
        return
    for item in items:
        print(item)


def commit_dir_mode() -> None:
    """`agent-guard.py --commit-dir`: read a Bash payload and print the
    directory the first commit-writing git command on the line runs in, after
    any `cd` / `git -C`, resolved from the payload's cwd. Prints nothing when
    there is no commit, the directory cannot be read literally, or the deadline
    passes, so the caller keeps its own cwd. Never executes anything."""
    _arm_deadline(lambda: _exit_with(""))
    try:
        data = json.load(sys.stdin)
        cmd = (data.get("tool_input") or {}).get("command", "")
        base = data.get("cwd") or os.getcwd()
    except Exception:
        return
    if not isinstance(cmd, str) or not cmd:
        return
    try:
        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
        import unattended_policy

        dirs = unattended_policy.git_commit_dirs(cmd, base)
    except Exception:
        return
    if dirs and dirs[0] and os.path.isdir(dirs[0]):
        print(dirs[0])


def main() -> None:
    if len(sys.argv) > 1 and sys.argv[1] == "--git-subcommand":
        git_subcommand_mode()
        return
    if len(sys.argv) > 1 and sys.argv[1] == "--commit-scope":
        commit_scope_mode()
        return
    if len(sys.argv) > 1 and sys.argv[1] == "--commit-dir":
        commit_dir_mode()
        return
    strict = unattended()
    seen = {"text": "", "raw": True}

    def _unjudged(default: str) -> str:
        # The always-on rules still hold when the full decision could not be
        # reached: a linear scan of whatever text is at hand decides them.
        text = seen["text"]
        if seen.get("raw"):
            try:
                cmd = (json.loads(text).get("tool_input") or {}).get("command", "")
                text = cmd if isinstance(cmd, str) else text
            except Exception:
                pass
        try:
            verdict = fallback_verdict(text)
        except Exception:
            verdict = "OK"
        return verdict if verdict != "OK" else default

    try:
        seen["text"] = sys.stdin.read()
    except Exception:
        seen["text"] = ""
    # Armed once the payload is in hand, so the fallback always has the text.
    _arm_deadline(
        lambda: _exit_with(_unjudged("BLOCK_UNATTENDED\tthe guard hit its internal deadline" if strict else "OK"))
    )
    try:
        data = json.loads(seen["text"])
        cmd = (data.get("tool_input") or {}).get("command", "")
        if isinstance(cmd, str):
            seen["text"], seen["raw"] = cmd, False
        verdict = decide(cmd) if isinstance(cmd, str) and cmd else "OK"
        if verdict == "OK" and strict:
            sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
            import unattended_policy

            reason = unattended_policy.check(data)
            if reason:
                verdict = "BLOCK_UNATTENDED\t" + reason.replace("\n", " ")
        print(verdict)
    except Exception:
        # Attended: a guard bug must not break a legitimate tool call.
        # Unattended: nobody is there to notice a gap, so an input the guard
        # cannot judge is refused.
        print(_unjudged("BLOCK_PARSE" if strict else "OK"))


if __name__ == "__main__":
    main()
