#!/usr/bin/env python3
"""Unattended policy for agent-guard.py: what a run nobody is watching may not do.

Consulted only when MULTI_AGENT_UNATTENDED=1 is in the hook's own environment,
which the autopilot runner sets on the `claude` process it spawns. It is never
read from agent-state.json: the run writes that file, and a rule the run can
switch off is not a rule. With the variable unset this module is not imported.

The model is refuse-what-it-cannot-parse. A Bash command is accepted only when
it reduces to simple commands joined by `;`, `&&`, `||` and plain pipes between
non-interpreter commands. Anything that hides a command from a line reader -
a subshell, a group, a substitution, a heredoc, a background job, a shell
keyword, a pipe into an interpreter, a `-c` on a shell - is refused outright,
because the guard reads the command line and cannot judge what it cannot parse.
The command families it can parse (git, gh, curl, installs, writes) are then
checked against the lists in ../schemas/unattended-policy.json.

Every check returns None (allowed) or a short reason string (blocked). Nothing
here executes the inspected command; the only processes it starts are read-only
`git` reads, memoized per invocation. Contract:
multi-agent-refs/features/unattended-security.md.
"""
import json
import os
import re
import shlex
import stat
from urllib.parse import urlsplit

HERE = os.path.dirname(os.path.abspath(__file__))
POLICY_CANDIDATES = [
    os.path.join(HERE, "..", "schemas", "unattended-policy.json"),
    os.path.join(HERE, "..", "pipeline", "schemas", "unattended-policy.json"),
]
PR_REQUESTS_SUBDIR = "pr-requests"
PR_VERDICT_SUFFIX = ".verdict.json"
UNATTENDED_RUN_DIR = ".multi-agent-unattended"

DARWIN = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False
MAX_SEGMENTS = 50

# --- shell structure the guard refuses to reason about -----------------------

# Command position tokens that are not simple commands.
SHELL_KEYWORDS = {
    "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done",
    "case", "esac", "function", "select", "eval", "exec", "source", ".",
    "export", "trap", "set", "unset", "alias", "unalias", "declare", "typeset",
    "readonly", "let", "return", "coproc", "time",
}
SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
INTERPRETERS = SHELLS | {"node", "perl", "ruby", "osascript", "php", "lua", "tclsh", "wish", "rscript"}
# Wrappers that run the rest of the line as a command; their option values are
# consumed so the wrapped command is what gets judged.
WRAPPER_VALUE_OPTS = {
    "env": {"-u", "-P", "--unset", "--block-signal", "--default-signal", "--ignore-signal"},
    "nice": {"-n", "--adjustment"},
    "timeout": {"-s", "--signal", "-k", "--kill-after"},
    "gtimeout": {"-s", "--signal", "-k", "--kill-after"},
    "ionice": {"-c", "-n", "-p"},
    "nohup": set(),
    "caffeinate": set(),
    "command": set(),
    "builtin": set(),
    "stdbuf": {"-i", "-o", "-e"},
    "xcrun": {"-sdk", "--sdk", "-toolchain", "--toolchain"},
    "arch": {"-arch", "-d"},
    "sandbox-exec": {"-f", "-n", "-p", "-D"},
}
# Wrappers whose command line the guard does not model: `script` takes an
# output file before the command, `watch` hands its arguments to `sh -c`, a
# debugger runs its target under its own control (and takes commands that spawn
# a shell), and a job runner (parallel, xargs) or a locker (flock) hands the
# rest to a shell or repeats it per input. `chroot` and `unbuffer` and the
# traces run their target too. Each is refused rather than parsed.
OPAQUE_WRAPPERS = {
    "script", "watch",
    "lldb", "gdb", "lldb-mi",
    "parallel", "flock", "unbuffer", "chroot", "dtruss", "dtrace", "ktrace",
    "strace", "ltrace", "rlwrap", "expect",
    "automator", "shortcuts", "osacompile",
}
ENV_REFUSED_OPTS = ("-S", "--split-string", "-C", "--chdir")
XCRUN_LOOKUPS = ("-f", "--find", "--show-sdk-path", "--show-sdk-version", "--show-sdk-build-version",
                 "--show-sdk-platform-path", "--show-sdk-platform-version", "--version", "-version", "-h", "--help")


def _is_interpreter(base):
    return base in INTERPRETERS or base.startswith("python")


def _casefold(s):
    return s.casefold() if DARWIN else s


def _strip_quotes(cmd, keep_double):
    """cmd with quoted spans removed. keep_double keeps the CONTENT of
    double-quoted spans (where `$(` and backticks stay active) while dropping
    single-quoted spans (inert). A backslash escapes the next char outside
    single quotes."""
    out = []
    q = None
    i = 0
    n = len(cmd)
    while i < n:
        c = cmd[i]
        if q is None:
            if c == "\\" and i + 1 < n:
                i += 2
                continue
            if c == "'":
                q = "'"
            elif c == '"':
                q = '"'
            else:
                out.append(c)
        elif q == "'":
            if c == "'":
                q = None
        else:  # double quote
            if c == "\\" and i + 1 < n:
                if keep_double:
                    out.append(cmd[i + 1])
                i += 2
                continue
            if c == '"':
                q = None
            elif keep_double:
                out.append(c)
        i += 1
    return "".join(out)


class Refused(Exception):
    """The line uses shell syntax the guard does not analyse."""


PLACEHOLDER_RX = re.compile(r"__MA_SUBST_(\d+)__", re.I)


def _placeholder(k):
    return f"__MA_SUBST_{k}__"


def _has_ph(s):
    return bool(s) and bool(PLACEHOLDER_RX.search(s))


def _match_paren(cmd, start):
    """Index of the `)` closing a `$(` whose body starts at `start`. Quote-aware
    and balanced; a nested `$(` or an unterminated body is refused."""
    depth = 1
    q = None
    i = start
    n = len(cmd)
    while i < n:
        c = cmd[i]
        if q == "'":
            if c == "'":
                q = None
            i += 1
            continue
        if c == "\\" and i + 1 < n:
            i += 2
            continue
        if q == '"':
            if c == '"':
                q = None
            elif c == "$" and cmd.startswith("$(", i):
                raise Refused("a command substitution nested inside another is not analysed")
            elif c == "`":
                raise Refused("a backtick substitution hides a command from the guard")
            i += 1
            continue
        if c in ("'", '"'):
            q = c
        elif c == "$" and cmd.startswith("$(", i):
            raise Refused("a command substitution nested inside another is not analysed")
        elif c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    raise Refused("an unterminated command substitution cannot be parsed")


HEREDOC_TOKEN = "__MA_HEREDOC__"


def _heredoc_delim(cmd, i):
    """At an unquoted `<<` (not `<<<`): (end, delimiter, dash, quoted)."""
    j = i + 2
    dash = j < len(cmd) and cmd[j] == "-"
    if dash:
        j += 1
    while j < len(cmd) and cmd[j] in " \t":
        j += 1
    if j < len(cmd) and cmd[j] in ("'", '"'):
        qc = cmd[j]
        k = cmd.find(qc, j + 1)
        if k < 0:
            raise Refused("an unterminated heredoc delimiter cannot be parsed")
        return k + 1, cmd[j + 1:k], dash, True
    quoted = j < len(cmd) and cmd[j] == "\\"
    if quoted:
        j += 1
    m = re.match(r"[A-Za-z0-9_.-]+", cmd[j:])
    if not m:
        raise Refused("a heredoc without a readable delimiter cannot be parsed")
    return j + m.end(), m.group(0), dash, quoted


def strip_heredocs(cmd):
    """cmd with every heredoc body removed and its `<<'DELIM'` replaced by an
    input redirect from a placeholder. A body behind a quoted delimiter is
    literal data. An unquoted delimiter expands `$(...)` inside the body, which
    the guard does not analyse, so it is refused. Quote-aware, and `$(` opens a
    fresh quoting context the way the shell does."""
    out = []
    i = 0
    n = len(cmd)
    q = None
    stack = []
    depth = 0
    pending = []
    while i < n:
        c = cmd[i]
        if q == "'":
            out.append(c)
            if c == "'":
                q = None
            i += 1
            continue
        if c == "\\" and i + 1 < n:
            out.append(cmd[i:i + 2])
            i += 2
            continue
        if q == '"':
            if c == '"':
                q = None
            elif cmd.startswith("$(", i):
                stack.append((q, depth))
                q, depth = None, 0
                out.append("$(")
                i += 2
                continue
            out.append(c)
            i += 1
            continue
        if c in ("'", '"'):
            q = c
            out.append(c)
            i += 1
            continue
        if cmd.startswith("$(", i):
            stack.append((None, depth))
            depth = 0
            out.append("$(")
            i += 2
            continue
        if c == "(":
            depth += 1
        elif c == ")":
            if depth == 0 and stack:
                q, depth = stack.pop()
                out.append(c)
                i += 1
                continue
            depth -= 1
        if cmd.startswith("<<", i) and not cmd.startswith("<<<", i) and not (i > 0 and cmd[i - 1] == "<"):
            end, delim, dash, quoted = _heredoc_delim(cmd, i)
            if not quoted:
                raise Refused("a heredoc with an unquoted delimiter expands its body, which the guard does not analyse")
            out.append(f"< {HEREDOC_TOKEN} ")
            pending.append((delim, dash))
            i = end
            continue
        if c == "\n" and pending:
            out.append("\n")
            i += 1
            for delim, dash in pending:
                while True:
                    if i >= n:
                        raise Refused("an unterminated heredoc cannot be parsed")
                    k = cmd.find("\n", i)
                    line = cmd[i:] if k < 0 else cmd[i:k]
                    i = n if k < 0 else k + 1
                    if (line.lstrip("\t") if dash else line) == delim:
                        break
            pending = []
            continue
        out.append(c)
        i += 1
    if pending:
        raise Refused("an unterminated heredoc cannot be parsed")
    return "".join(out)


def extract_substitutions(cmd):
    """(outer, inners): every top-level `$(...)` replaced by an indexed
    placeholder, and the inner command texts. Single-quoted text is literal.
    `$((` arithmetic and an unbalanced quote are refused."""
    out = []
    inners = []
    q = None
    i = 0
    n = len(cmd)
    while i < n:
        c = cmd[i]
        if q == "'":
            out.append(c)
            if c == "'":
                q = None
            i += 1
            continue
        if c == "\\" and i + 1 < n:
            out.append(cmd[i:i + 2])
            i += 2
            continue
        if c == '"':
            q = None if q == '"' else '"'
            out.append(c)
            i += 1
            continue
        if c == "'" and q is None:
            q = "'"
            out.append(c)
            i += 1
            continue
        if c == "$" and cmd.startswith("$((", i):
            raise Refused("arithmetic expansion $((...)) is not analysed")
        if c == "$" and cmd.startswith("$(", i):
            j = _match_paren(cmd, i + 2)
            inners.append(cmd[i + 2:j])
            out.append(_placeholder(len(inners) - 1))
            i = j + 1
            continue
        out.append(c)
        i += 1
    if q is not None:
        raise Refused("an unbalanced quote leaves the command unparseable")
    return "".join(out), inners


BACKGROUND = re.compile(r"(?<![&>0-9])&(?![&>])")
SUBST = re.compile(r"\$\(|`|<\(|>\(")
HEREDOC = re.compile(r"(?<!<)<<(?!<)")
GROUPING = re.compile(r"[(){}]")
OP_SPLIT = re.compile(r"\|\||&&|;|\n|\|")


def split_ops(cmd):
    """The simple-command segments of a line, split on unquoted `;`, `&&`,
    `||`, `|`, `|&` and newlines - the way the shell splits them, so an
    operator inside a quoted sed, jq or awk program is not a separator."""
    segs = []
    cur = []
    q = None
    i = 0
    n = len(cmd)
    while i < n:
        c = cmd[i]
        if q == "'":
            cur.append(c)
            if c == "'":
                q = None
            i += 1
            continue
        if c == "\\" and i + 1 < n:
            cur.append(cmd[i:i + 2])
            i += 2
            continue
        if q == '"':
            cur.append(c)
            if c == '"':
                q = None
            i += 1
            continue
        if c in ("'", '"'):
            q = c
            cur.append(c)
            i += 1
            continue
        two = cmd[i:i + 2]
        if two in ("&&", "||", "|&"):
            segs.append("".join(cur))
            cur = []
            i += 2
            continue
        if c == "|" and cur and cur[-1] == ">":
            cur.append(c)
            i += 1
            continue
        if c in (";", "|", "\n"):
            segs.append("".join(cur))
            cur = []
            i += 1
            continue
        cur.append(c)
        i += 1
    segs.append("".join(cur))
    return segs


WRITE_REDIRECTS = {">", ">>", ">|", "&>", "&>>", "<>"}


def redirect_targets(seg):
    """Files a segment's redirections write, read with a quote-aware lexer so a
    `>` inside quotes is text and a quoted target is still seen."""
    try:
        lex = shlex.shlex(seg, posix=True, punctuation_chars=True)
        lex.whitespace_split = True
        toks = list(lex)
    except ValueError:
        raise Refused("an unbalanced quote leaves the redirections unparseable")
    out = []
    for i, t in enumerate(toks):
        if i + 1 >= len(toks):
            continue
        nxt = toks[i + 1]
        if t in WRITE_REDIRECTS:
            out.append(nxt)
        elif t == ">&" and not (nxt.isdigit() or nxt == "-"):
            out.append(nxt)
    return out


SIMPLE_PARAM = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}")


def structural_refusal(cmd):
    """A reason the command cannot be reduced to simple commands, or None.
    Called after heredoc bodies are stripped and `$(...)` extracted, so what is
    left of `<<` is a here-string and any `$(` left is unanalysable."""
    meta = SIMPLE_PARAM.sub("$P", _strip_quotes(cmd, keep_double=False))
    subst = _strip_quotes(cmd, keep_double=True)
    if SUBST.search(subst):
        return "a backtick or process substitution hides a command from the guard"
    if HEREDOC.search(meta):
        return "a heredoc the guard could not strip feeds a command it cannot see"
    if GROUPING.search(meta):
        return "a subshell, brace group or non-trivial ${...} expansion hides a command from the guard"
    if BACKGROUND.search(meta):
        return "a background job (&) runs a command the guard cannot wait on"
    segs = split_ops(cmd)
    if len(segs) > MAX_SEGMENTS:
        return f"the command has more than {MAX_SEGMENTS} segments and is refused unparsed"
    return None


# shlex is quadratic in a token's length; a longer segment is split on
# whitespace with quotes and backslashes removed (agent-guard.py coarse_tokens).
SHLEX_MAX_CHARS = 16384


def tokenize(text):
    if len(text) > SHLEX_MAX_CHARS:
        return [re.sub(r"[\"'\\]", "", t) for t in text.split()]
    try:
        return shlex.split(text)
    except ValueError:
        return text.split()


ASSIGN_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")


def _walk_wrappers(toks):
    """(assignments, command, refusal) for a token list: the VAR=value
    assignments that apply to the command (leading ones, those after `env`,
    and `arch -e` values), the wrapped command with every wrapper and its
    option values consumed, and a reason when a wrapper hides the command."""
    assigns = []
    i = 0
    while i < len(toks):
        t = toks[i]
        if ASSIGN_NAME.match(t):
            assigns.append(t)
            i += 1
            continue
        base = os.path.basename(t).lower()
        if base in OPAQUE_WRAPPERS:
            return assigns, toks[i:], f"{base} runs the rest of the line in a way the guard does not model"
        if base not in WRAPPER_VALUE_OPTS:
            break
        value_opts = WRAPPER_VALUE_OPTS[base]
        i += 1
        while i < len(toks) and toks[i].startswith("-"):
            opt = toks[i]
            i += 1
            if base == "env" and any(opt == o or opt.startswith(o + ("=" if o.startswith("--") else "")) for o in ENV_REFUSED_OPTS):
                return assigns, toks[i:], "env -S / -C re-splits the command or moves it to another directory"
            if base == "arch" and opt == "-e":
                if i < len(toks):
                    assigns.append(toks[i])
                    i += 1
                continue
            if base == "xcrun" and (opt in XCRUN_LOOKUPS or opt.startswith("--show-sdk")):
                return assigns, [], None
            if opt in value_opts and i < len(toks):
                i += 1
        # timeout takes a bare DURATION positional before the command.
        if base in ("timeout", "gtimeout") and i < len(toks) and re.match(r"^[0-9]", toks[i]):
            i += 1
    return assigns, toks[i:], None


def _strip_wrappers(toks):
    """Drop env assignments and wrappers that run the rest as a command,
    consuming each wrapper's option VALUES so the wrapped command survives
    intact. sudo, doas, xargs-with-a-command and shell keywords are NOT
    stripped here; they are refused by simple_command_refusal."""
    return _walk_wrappers(toks)[1]


REDIR_TOKEN = re.compile(r"^\d*(>>|>\||>&|>|<<<|<>|<&|<|&>>|&>)(.*)$")
INTERP_VALUE_OPTS = {"-r", "--require", "--import", "--loader", "--experimental-loader", "--input-type",
                     "-C", "--conditions", "-W", "-X", "-I", "-M"}
INTERP_INLINE = {"-e", "-E", "-p", "--eval", "--print"}


def _operands(args):
    """args with redirection operators and their targets removed."""
    out = []
    i = 0
    while i < len(args):
        m = REDIR_TOKEN.match(args[i])
        if m:
            i += 1 if m.group(2) else 2
            continue
        out.append(args[i])
        i += 1
    return out


def _program_source(low, args):
    """What an interpreter runs: "script" (a named file or module), "inline"
    (code on the command line), or "stdin" (no program named, so it reads the
    program from its input - a pipe, here-string or heredoc)."""
    ops = _operands(args)
    j = 0
    while j < len(ops):
        a = ops[j]
        if a == "--":
            return ("script", ops[j + 1]) if j + 1 < len(ops) else ("stdin", "")
        if a == "-":
            return "stdin", ""
        if a.startswith("-"):
            if low.startswith("python") and a == "-m":
                return ("module", ops[j + 1]) if j + 1 < len(ops) else ("stdin", "")
            if a in INTERP_INLINE:
                return "inline", ""
            if low in ("sh", "bash", "zsh", "dash", "ksh", "fish") and a == "-s":
                return "stdin", ""
            j += 2 if a in INTERP_VALUE_OPTS else 1
            continue
        return "script", a
    return "stdin", ""


_TRACK_CACHE = {}


def _run_base_sha(env=None):
    env = os.environ if env is None else env
    sha = (env.get("MA_RUN_BASE_SHA") or "").strip()
    return sha if re.match(r"^[0-9a-f]{7,40}$", sha) else None


_TOP_CACHE = {}


def _worktree_top(d):
    if d in _TOP_CACHE:
        return _TOP_CACHE[d]
    try:
        import subprocess

        r = subprocess.run(["git", "-C", d, "rev-parse", "--show-toplevel"],
                           capture_output=True, text=True, timeout=3)
        top = os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
    except Exception:
        top = None
    _TOP_CACHE[d] = top
    return top


def _tracked_clean(path, env=None, cwd=None):
    """path is a regular file the run may run as a program: it is tracked by
    git, unchanged in the working tree, AND present unchanged at the run's BASE
    commit (MA_RUN_BASE_SHA). A file the run committed itself is clean against
    HEAD but is not in the base, so it does not count. The path must sit inside
    the RUN's worktree (the one cwd is in); a file in another clone that happens
    to share the base commit does not count. Without a base sha the guard cannot
    prove provenance, so it refuses."""
    p = os.path.realpath(path)
    key = (p, _run_base_sha(env) or "", os.path.realpath(cwd) if cwd else "")
    if key in _TRACK_CACHE:
        return _TRACK_CACHE[key]
    ok = False
    base = _run_base_sha(env)
    run_top = _worktree_top(cwd) if cwd else None
    if base and os.path.isfile(p):
        d, name = os.path.split(p)
        if run_top is not None and _worktree_top(d) != run_top:
            _TRACK_CACHE[key] = False
            return False
        try:
            import subprocess

            def g(*a):
                return subprocess.run(["git", "-C", d, *a], capture_output=True, text=True, timeout=3)

            # The file's path relative to the worktree root, so the base lookup
            # names it correctly and a file outside this worktree is rejected.
            top = _worktree_top(d)
            ls = g("ls-files", "--error-unmatch", "--", name)
            st = g("status", "--porcelain", "--untracked-files=all", "--", name)
            in_worktree = top is not None
            rel = os.path.relpath(p, top) if in_worktree else None
            at_base = (
                g("cat-file", "-e", f"{base}:{rel}").returncode == 0
                and g("diff", "--quiet", base, "--", name).returncode == 0
                if rel and not rel.startswith("..")
                else False
            )
            ok = (
                in_worktree
                and ls.returncode == 0
                and st.returncode == 0
                and not st.stdout.strip()
                and at_base
            )
        except Exception:
            ok = False
    _TRACK_CACHE[key] = ok
    return ok


GLOB_CHARS = "*?["


def _program_refusal(what, raw, cwd, variables, env):
    """Reason `what` may not run the file `raw`, or None. A run may execute the
    installed pipeline (~/.claude/scripts, ~/.claude/lib), a file it cannot
    write, a binary npm linked into node_modules/.bin, or a file committed at
    HEAD and left unmodified - never a file the run itself wrote or edited."""
    e = expand(raw, variables or {}, env)
    if e is None:
        return f"{what} runs a file whose name is built at run time"
    if any(ch in e for ch in GLOB_CHARS):
        return f"{what} runs a file named by a glob pattern"
    e = os.path.expanduser(re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), e))
    if not os.path.isabs(e) and cwd is None:
        return f"{what} runs a file relative to a directory the guard could not resolve"
    literal = os.path.normpath(e if os.path.isabs(e) else os.path.join(cwd, e))
    real = os.path.realpath(literal)
    # A program under /dev (/dev/stdin, /dev/fd/N) or any non-regular file
    # (a FIFO, a device) is the program the guard cannot see: its bytes are the
    # command's own input, not a file on disk it can judge.
    if literal == "/dev" or literal.startswith("/dev/") or real == "/dev" or real.startswith("/dev/"):
        return f"{what} runs a program read from {raw[:40]}, which the guard cannot see"
    try:
        st = os.stat(real)
        if not stat.S_ISREG(st.st_mode):
            return f"{what} runs {raw[:40]}, which is not a regular file (a FIFO, device or socket)"
    except OSError:
        pass
    for h in {home(), os.path.expanduser("~")}:
        for sub in ("scripts", "lib"):
            base = os.path.join(h, ".claude", sub)
            if _under(literal, base) or _under(real, os.path.realpath(base)):
                return None
    if (os.sep + os.path.join("node_modules", ".bin") + os.sep) in literal:
        return None
    if os.path.exists(real) and not os.access(real, os.W_OK) and not os.access(os.path.dirname(real), os.W_OK):
        return None
    if _tracked_clean(real, env, cwd):
        return None
    return (f"{what} runs {raw[:60]}, which is neither an installed pipeline script nor a file "
            "committed unmodified at the run's base commit")


# Interpreter options that put the program on the command line, by interpreter:
# (short letters that mean "inline code", short letters that take a value and
# end the cluster). python -c and sh -c are covered by the `-c` rule.
INLINE_SHORT = {
    "node": ("ep", "r"),
    "bun": ("ep", "r"),
    "ruby": ("e", "IrCEFKTWx0"),
    "perl": ("eE", "IMmixCdD"),
    "lua": ("e", "l"),
    "osascript": ("e", "l"),
    "swift": ("e", ""),
    "rscript": ("e", ""),
    "php": ("rRBE", "cdzfF"),
}
INLINE_LONG = {"--eval", "--print"}


def _inline_flag(low, args):
    if low not in INLINE_SHORT:
        return None
    inline, valued = INLINE_SHORT[low]
    for a in args:
        if a == "--":
            return None
        if a.split("=", 1)[0] in INLINE_LONG and low in ("node", "bun"):
            return a
        if a.startswith("-") and not a.startswith("--") and len(a) > 1:
            for ch in a[1:]:
                if ch in inline:
                    return a
                if ch in valued:
                    break
        elif not a.startswith("-"):
            return None
    return None


SCRIPT_EXT = re.compile(r"\.(m?js|cjs|ts|tsx|jsx|mts|cts)$")


def _module_refusal(what, name, cwd, variables, env):
    """python -m NAME imports NAME from the cwd first: a module the run wrote
    there is a file the run executes."""
    if cwd is None:
        return f"{what} imports a module relative to a directory the guard could not resolve"
    first = name.split(".", 1)[0]
    for cand in (first + ".py", first):
        if os.path.exists(os.path.join(cwd, cand)):
            return _program_refusal(what, cand, cwd, variables, env)
    return None


NODE_PRELOAD_OPTS = ("-r", "--require", "--import", "--loader", "--experimental-loader")


def _node_preload_refusal(base, args, cwd, variables, env):
    """node -r / --require / --import / --loader FILE runs FILE before the main
    script; a file the run wrote there executes just as a script argument would.
    The value is checked with _program_refusal."""
    i = 0
    while i < len(args):
        a = args[i]
        if a == "--":
            break
        val = None
        for opt in NODE_PRELOAD_OPTS:
            if a == opt and i + 1 < len(args):
                val = args[i + 1]
                i += 1
                break
            if a.startswith(opt + "="):
                val = a.split("=", 1)[1]
                break
        if val is not None:
            if "://" in val:
                return f"{base} {a.split('=', 1)[0]} preloads a remote module"
            r = _program_refusal(f"{base} {a.split('=', 1)[0]}", val, cwd, variables, env)
            if r:
                return r
        i += 1
    return None


def interpreter_refusal(stripped, cwd, variables=None, env=None):
    """What an interpreter runs must be a file the run did not write: inline
    code (-e, --eval, -p, php -r, deno eval) is refused, a script file is
    checked with _program_refusal, and deno, bun and swift are judged by
    their subcommand."""
    base = os.path.basename(stripped[0])
    low = base.lower()
    args = stripped[1:]
    flag = _inline_flag(low, args)
    if flag:
        return f"{base} {flag} runs an inline program the guard cannot see"
    plains = [a for a in _operands(args) if not a.startswith("-")]
    if low == "deno":
        sub = plains[0] if plains else ""
        if sub in ("eval", "repl"):
            return f"deno {sub} runs an inline program the guard cannot see"
        target = plains[1] if sub == "run" and len(plains) > 1 else (sub if SCRIPT_EXT.search(sub) or "/" in sub else "")
        if target:
            if "://" in target:
                return "deno runs a remote module"
            return _program_refusal(base, target, cwd, variables, env)
        return None
    if low == "bun":
        sub = plains[0] if plains else ""
        target = plains[1] if sub == "run" and len(plains) > 1 else ("" if sub == "run" else sub)
        if target and (SCRIPT_EXT.search(target) or "/" in target):
            return _program_refusal(base, target, cwd, variables, env)
        return None
    if low == "swift":
        sub = plains[0] if plains else ""
        if sub and (sub.endswith(".swift") or "/" in sub):
            return _program_refusal(base, sub, cwd, variables, env)
        return None
    if low in ("node", "nodejs"):
        r = _node_preload_refusal(base, args, cwd, variables, env)
        if r:
            return r
    if not _is_interpreter(low):
        return None
    for a in args:
        if a.startswith("-") and not a.startswith("--") and "c" in a[1:]:
            return f"{base} -c runs an inline program the guard cannot see"
    kind, script = _program_source(low, args)
    if kind == "stdin":
        return f"{base} with no script reads its program from stdin (a pipe, here-string or heredoc)"
    if kind == "inline" and not low.startswith("python"):
        return f"{base} runs an inline program the guard cannot see"
    if _has_ph(script):
        return f"{base} runs a script whose name is built at run time"
    if kind == "module":
        return _module_refusal(base, script, cwd, variables, env)
    if kind == "script":
        return _program_refusal(base, script, cwd, variables, env)
    return None


AWKS = {"awk", "gawk", "mawk", "nawk"}
AWK_DANGER = re.compile(r"\bsystem\s*\(|(?<!\|)\|(?!\|)|\bgetline\b|\bprintf?\b[^;{}]*>|@load|@include")


def awk_refusal(stripped, cwd, variables=None, env=None):
    """An awk program may not start commands (system, a pipe, getline) or
    write files (print > file); a program file must be one the run did not
    write."""
    args = stripped[1:]
    progs, files = [], []
    i = 0
    while i < len(args):
        a = args[i]
        if a == "--":
            i += 1
            break
        if not a.startswith("-") or a == "-":
            break
        key = a.split("=", 1)[0]
        if key in ("-i", "--include", "-l", "--load", "-E", "--exec"):
            return f"awk {key} loads code the guard cannot see"
        if a in ("-f", "--file", "-e", "--source") and i + 1 < len(args):
            (files if a in ("-f", "--file") else progs).append(args[i + 1])
            i += 2
            continue
        if key in ("--file", "--source") and "=" in a:
            (files if key == "--file" else progs).append(a.split("=", 1)[1])
        elif a.startswith("-f") and len(a) > 2:
            files.append(a[2:])
        elif a in ("-F", "-v") and i + 1 < len(args):
            i += 1
        i += 1
    if not files and not progs and i < len(args):
        progs.append(args[i])
    for f in files:
        r = _program_refusal("awk -f", f, cwd, variables, env)
        if r:
            return r
    for p in progs:
        if AWK_DANGER.search(p):
            return "an awk program with system, a pipe, getline or an output redirect runs or writes what the guard cannot see"
    return None


def _skip_sed_regex(s, i, delim):
    n = len(s)
    while i < n and s[i] != delim:
        if s[i] == "\\":
            i += 1
        elif s[i] == "\n":
            break
        i += 1
    return i + 1


def sed_program_refusal(script):
    """A sed program that writes a file (w, W, the s///w flag) or runs a
    command (e, the s///e flag). A light scanner: it walks addresses and
    commands, skipping regex and replacement text."""
    s = script
    n = len(s)
    i = 0
    reason = "a sed program with a w or e command writes a file or runs a command the guard cannot see"
    while i < n:
        if s[i] in " \t\n;{}!":
            i += 1
            continue
        for _ in range(2):
            if i < n and s[i].isdigit():
                while i < n and (s[i].isdigit() or s[i] == "~"):
                    i += 1
            elif i < n and s[i] == "$":
                i += 1
            elif i < n and s[i] == "/":
                i = _skip_sed_regex(s, i + 1, "/")
                while i < n and s[i] in "IM":
                    i += 1
            elif i < n - 1 and s[i] == "\\":
                i = _skip_sed_regex(s, i + 2, s[i + 1])
            else:
                break
            while i < n and s[i] in " \t":
                i += 1
            if i < n and s[i] == ",":
                i += 1
                while i < n and s[i] in " \t":
                    i += 1
                continue
            break
        while i < n and s[i] in " \t!":
            i += 1
        if i >= n:
            break
        c = s[i]
        if c in "wWe":
            return reason
        if c in "sy" and i + 1 < n:
            d = s[i + 1]
            i = _skip_sed_regex(s, i + 2, d)
            i = _skip_sed_regex(s, i, d)
            if c == "s":
                j = i
                while j < n and s[j] not in ";\n} \t":
                    j += 1
                if "w" in s[i:j] or "e" in s[i:j]:
                    return reason
                i = j
            continue
        if c in "aicrR":
            k = s.find("\n", i)
            i = n if k < 0 else k + 1
            continue
        if c in "btT:":
            while i < n and s[i] not in ";\n":
                i += 1
            continue
        i += 1
    return None


def sed_refusal(stripped, cwd, variables=None, env=None):
    args = stripped[1:]
    progs, files, ops = [], [], []
    i = 0
    while i < len(args):
        a = args[i]
        if a in ("-e", "--expression") and i + 1 < len(args):
            progs.append(args[i + 1])
            i += 2
            continue
        if a.startswith("--expression="):
            progs.append(a.split("=", 1)[1])
        elif a.startswith("-e") and len(a) > 2 and not a.startswith("--"):
            progs.append(a[2:])
        elif a in ("-f", "--file") and i + 1 < len(args):
            files.append(args[i + 1])
            i += 2
            continue
        elif a.startswith("--file="):
            files.append(a.split("=", 1)[1])
        elif a == "-i" and i + 1 < len(args) and (args[i + 1] == "" or (args[i + 1].startswith(".") and "/" not in args[i + 1])):
            i += 2
            continue
        elif a in ("-l", "--line-length") and i + 1 < len(args):
            i += 2
            continue
        elif not a.startswith("-") or a == "-":
            ops.append(a)
        i += 1
    if not progs and not files and ops:
        progs.append(ops[0])
    for f in files:
        r = _program_refusal("sed -f", f, cwd, variables, env)
        if r:
            return r
    for p in progs:
        r = sed_program_refusal(p)
        if r:
            return r
    return None


MAKEFILES = ("GNUmakefile", "makefile", "Makefile")


def make_refusal(stripped, cwd, variables=None, env=None):
    """make runs the recipes of a Makefile: it has to be one committed
    unmodified at HEAD, and the command line may not add code (--eval) or
    override a variable a recipe expands (CC=...)."""
    args = stripped[1:]
    files, directory = [], None
    i = 0
    while i < len(args):
        a = args[i]
        key = a.split("=", 1)[0]
        if key in ("-E", "--eval"):
            return "make --eval adds rules the guard cannot see"
        if a in ("-f", "--file", "--makefile") and i + 1 < len(args):
            files.append(args[i + 1])
            i += 2
            continue
        if key in ("--file", "--makefile") and "=" in a:
            files.append(a.split("=", 1)[1])
        elif a.startswith("-f") and len(a) > 2:
            files.append(a[2:])
        elif a in ("-C", "--directory") and i + 1 < len(args):
            directory = args[i + 1]
            i += 2
            continue
        elif key == "--directory" and "=" in a:
            directory = a.split("=", 1)[1]
        elif a.startswith("-C") and len(a) > 2:
            directory = a[2:]
        elif a in ("-I", "--include-dir", "-o", "-W", "--old-file", "--what-if", "--new-file", "--assume-old", "--assume-new") and i + 1 < len(args):
            i += 2
            continue
        elif not a.startswith("-") and "=" in a:
            return f"make {key}=... overrides a variable a recipe runs"
        i += 1
    base = cwd
    if directory is not None:
        e = expand(directory, variables or {}, env)
        if e is None or (cwd is None and not os.path.isabs(e)):
            return "make -C into a directory the guard could not resolve"
        base = os.path.join(cwd or "/", os.path.expanduser(e))
    if base is None:
        return "make in a directory the guard could not resolve"
    if not files:
        try:
            entries = os.listdir(base)
        except OSError:
            entries = []
        found = [e for m in MAKEFILES for e in entries if _casefold(e) == _casefold(m)]
        files = found[:1]
    for f in files:
        r = _program_refusal("make", f, base, variables, env)
        if r:
            return r
    return None


def simple_command_refusal(cmd, toks, variables=None, env=None, cwd=None):
    """Refuse a segment that is not a simple command the guard can judge:
    a shell keyword, a runtime-built command name, sudo/doas, a shell with a
    `-c` cluster, an interpreter reading its program from stdin or from the
    command line, a program file the run wrote, find -exec, or xargs with a
    command."""
    variables = variables or {}
    raw = _strip_leading_assignments_for_check(tokenize(cmd))
    if raw and _has_ph(raw[0]):
        return "a command substitution in command position builds the command name at run time"
    if raw and "$" in raw[0]:
        e = expand(raw[0], variables, env)
        if e is None:
            return "the command name is built at run time and cannot be judged"
        raw = [e] + raw[1:]
    if raw:
        base = os.path.basename(raw[0])
        low = base.lower()
        if low in SHELL_KEYWORDS or base in SHELL_KEYWORDS:
            return f"{base} is a shell keyword, not a simple command"
        if low in ("sudo", "doas"):
            return f"{low} escalates privilege and is refused"
    stripped = _strip_wrappers(tokenize(cmd))
    if not stripped:
        return None
    if _has_ph(stripped[0]):
        return "the command name is built at run time and cannot be judged"
    if "$" in stripped[0]:
        e = expand(stripped[0], variables, env)
        if e is None:
            return "the command name is built at run time and cannot be judged"
        stripped = [e] + stripped[1:]
    base = os.path.basename(stripped[0])
    low = base.lower()
    if low in SHELL_KEYWORDS or base in SHELL_KEYWORDS:
        return f"{base} is a shell keyword, not a simple command"
    if low in ("sudo", "doas"):
        return f"{low} escalates privilege and is refused"
    if "/" in stripped[0]:
        r = _program_refusal("the command", stripped[0], cwd, variables, env)
        if r:
            return r
    for judge in (interpreter_refusal,
                  awk_refusal if low in AWKS else None,
                  sed_refusal if low in ("sed", "gsed") else None,
                  make_refusal if low in ("make", "gmake") else None):
        if judge:
            r = judge(stripped, cwd, variables, env)
            if r:
                return r
    if low in ("sort", "gsort", "rg", "grep", "ggrep"):
        r = _helper_program_refusal(low, stripped[1:], cwd, variables, env)
        if r:
            return r
    if low == "find":
        for a in stripped[1:]:
            if a in ("-exec", "-execdir", "-ok", "-okdir"):
                return "find -exec runs a command the guard cannot see"
    if low == "xargs":
        rest = stripped[1:]
        j = 0
        while j < len(rest) and rest[j].startswith("-"):
            j += 1
        if j < len(rest):
            return "xargs with a command runs it the guard cannot judge"
    return None


# Options whose value names a helper program a tool runs: sort's external merge
# compressor, ripgrep's and grep's preprocessor. A file the run wrote there
# executes.
HELPER_PROGRAM_OPTS = {
    "sort": ("--compress-program",),
    "gsort": ("--compress-program",),
    "rg": ("--pre",),
    "grep": (),
    "ggrep": (),
}


def _helper_program_refusal(low, args, cwd, variables, env):
    opts = HELPER_PROGRAM_OPTS.get(low, ())
    i = 0
    while i < len(args):
        a = args[i]
        val = None
        for opt in opts:
            if a == opt and i + 1 < len(args):
                val = args[i + 1]
                i += 1
                break
            if a.startswith(opt + "="):
                val = a.split("=", 1)[1]
                break
        if val is not None:
            r = _program_refusal(f"{low} {a.split('=', 1)[0]}", val, cwd, variables, env)
            if r:
                return r
        i += 1
    return None


def _strip_leading_assignments_for_check(toks):
    """Leading VAR=value assignments: refuse a dangerous one, else drop them and
    return the rest so the command can be judged."""
    i = 0
    while i < len(toks) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[i]):
        i += 1
    return toks[i:]


# Environment variables that make a later program run code, load a library,
# read another config or resolve paths differently.
BLOCKED_ENV = {
    "PATH", "CDPATH", "BASH_ENV", "ENV", "PROMPT_COMMAND", "SHELLOPTS", "BASHOPTS", "ZDOTDIR", "IFS",
    "HOME", "XDG_CONFIG_HOME", "NODE_OPTIONS", "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
    "PERL5OPT", "PERL5LIB", "RUBYOPT", "RUBYLIB", "SSH_ASKPASS", "LESSOPEN", "LESSCLOSE",
    # Proxies redirect every request somewhere the allowlist cannot check.
    "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
    "https_proxy", "http_proxy", "all_proxy", "no_proxy",
    # These point a fetcher, git or a toolchain at another config, host or CA.
    "CURL_HOME", "WGETRC", "GH_HOST", "GH_CONFIG_DIR", "BROWSER", "GH_BROWSER",
    "DEVELOPER_DIR", "TOOLCHAINS", "SDKROOT", "GRADLE_USER_HOME", "JAVA_TOOL_OPTIONS",
    "NODE_PATH", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE",
    "REQUESTS_CA_BUNDLE", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONFIG_FILE",
    "GEM_HOME", "GEM_PATH", "BUNDLE_GEMFILE",
}
BLOCKED_ENV_PREFIXES = ("MULTI_AGENT", "LD_", "DYLD_", "npm_config_", "NPM_CONFIG_", "GH_",
                        "PYTHON", "GRADLE_", "JAVA_", "MAVEN_")
# Variables that name a program git or a pager runs; only the no-op values pass.
PROGRAM_ENV = {"EDITOR", "VISUAL", "PAGER", "MANPAGER", "GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GIT_PAGER"}
PROGRAM_ENV_SAFE = {"", "cat", "true", ":", "less"}
# The GIT_* variables that only label a commit or turn off a prompt. Every
# other GIT_* is refused: GIT_CONFIG_COUNT/KEY_n/VALUE_n, GIT_CONFIG_GLOBAL,
# GIT_EXTERNAL_DIFF, GIT_SSH(_COMMAND), GIT_ASKPASS, GIT_EXEC_PATH, GIT_DIR and
# the rest reconfigure what git runs or where it looks.
SAFE_GIT_ENV = re.compile(
    r"^GIT_((AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|TERMINAL_PROMPT|OPTIONAL_LOCKS|LFS_SKIP_SMUDGE|FLUSH|ADVICE)$")


def assignment_refusal(assignment):
    key, _, value = assignment.partition("=")
    if key in PROGRAM_ENV:
        if value.strip("'\"") in PROGRAM_ENV_SAFE:
            return None
        return f"setting {key} names a program git or a pager runs"
    if key in BLOCKED_ENV or key.startswith(BLOCKED_ENV_PREFIXES):
        return f"setting {key} changes what the run executes or how the guard sees it"
    if key.startswith("GIT_") and not SAFE_GIT_ENV.match(key):
        return f"setting {key} reconfigures what git runs or where it looks"
    return None


def dangerous_assignment(toks):
    assigns, _, refusal = _walk_wrappers(toks)
    if refusal:
        return refusal
    for t in assigns:
        r = assignment_refusal(t)
        if r:
            return r
    return None


# --- policy data --------------------------------------------------------------

def load_policy():
    for p in POLICY_CANDIDATES:
        try:
            with open(p, encoding="utf-8") as f:
                return json.load(f)
        except (OSError, ValueError):
            continue
    raise RuntimeError("unattended-policy.json not found")


def home():
    return os.path.realpath(os.path.expanduser("~"))


def autopilot_root(env=None):
    env = os.environ if env is None else env
    root = env.get("MA_AUTOPILOT_ROOT") or os.path.join(home(), ".claude", "autopilot")
    return os.path.expanduser(root)


def unattended_run_root(env=None):
    env = os.environ if env is None else env
    return os.path.expanduser(env.get("MA_UNATTENDED_RUN_ROOT") or os.path.join(home(), UNATTENDED_RUN_DIR))


def pr_request_path(env=None):
    env = os.environ if env is None else env
    sid = env.get("MULTI_AGENT_SESSION_ID", "")
    if not re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", sid):
        return None
    return os.path.join(unattended_run_root(env), PR_REQUESTS_SUBDIR, sid + ".json")


def resolve(path, cwd):
    p = path.strip()
    p = re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), p)
    p = os.path.expanduser(p)
    if not os.path.isabs(p):
        p = os.path.join(cwd or os.getcwd(), p)
    return os.path.realpath(os.path.normpath(p))


# --- paths --------------------------------------------------------------------

def _under(target, base):
    """target == base or target is under base, casefolded on darwin."""
    t, b = _casefold(target), _casefold(base)
    return t == b or t.startswith(b + os.sep)


def check_path(path, cwd, policy, env=None):
    if not path:
        return None
    target = resolve(path, cwd)
    tcf = _casefold(target)
    exempt = pr_request_path(env)
    if exempt and tcf == _casefold(os.path.realpath(exempt)):
        return None
    requests = os.path.realpath(os.path.join(unattended_run_root(env), PR_REQUESTS_SUBDIR))
    if _under(target, requests):
        return "another session's PR request (or the requests directory itself) is not this run's to write"
    h = home()
    # The home directory itself, or an ancestor of it (rm -rf ~, mv ~ /tmp),
    # contains every host root and credential file below; writing it writes them.
    if tcf == _casefold(h) or _under(h, target):
        return "$HOME (or a directory above it) holds every host root and credential the guard protects"
    ap = os.path.realpath(autopilot_root(env))
    if _under(target, ap):
        return "the autopilot runner's own files (queue, config, requests, PR verification) are not the run's to write"
    for name in policy.get("protectedHomeFiles", []):
        if tcf == _casefold(os.path.join(h, name)):
            return f"~/{name} is host configuration"
    for d in policy.get("protectedHomeDirs", []):
        if _under(target, os.path.join(h, d)):
            return f"~/{d}/ is host configuration"
    # The whole host root is protected, not an enumerated list of entries: an
    # unattended run writes its logs and PR request under the unattended run
    # directory (lib/pr-request-location.mjs), never here, so nothing under
    # ~/.claude, ~/.copilot or ~/.codex is the run's to write.
    for root in policy.get("protectedHostRoots", []):
        base = os.path.join(h, root)
        if tcf == _casefold(base) or tcf.startswith(_casefold(base) + os.sep):
            return f"~/{root}/ is host configuration and the installed pipeline; an unattended run writes under ~/{UNATTENDED_RUN_DIR}"
    segs = target.split(os.sep)
    segs_cf = [_casefold(s) for s in segs]
    repo = policy.get("protectedRepoPaths", {})
    for d in repo.get("dirs", []):
        parts = [_casefold(p) for p in d.split("/")]
        for i in range(len(segs_cf) - len(parts) + 1):
            if segs_cf[i:i + len(parts)] == parts:
                return f"{d}/ is protected: workflows, hooks and agent config change what runs next"
        # A directory target that is an ancestor of a protected path (mv evil .github).
        for k in range(1, len(parts)):
            if segs_cf[-k:] == parts[:k]:
                return f"{'/'.join(d.split('/')[:k])} is protected: it holds {d}/"
    name = segs[-1]
    ncf = _casefold(name)
    if any(ncf == _casefold(f) for f in repo.get("files", [])):
        return f"{name} is protected"
    man = policy.get("manifests", {})
    if any(ncf == _casefold(f) for f in man.get("files", [])) or any(re.search(rx, name) for rx in man.get("patterns", [])):
        return f"{name} is a dependency manifest or lockfile; adding a package is a person's decision"
    return None


def _short_cluster_has(tok, ch):
    return tok.startswith("-") and not tok.startswith("--") and ch in tok[1:]


# Options of cp/mv/rsync/install/ditto whose next token is a VALUE, not a path.
COPY_VALUE_OPTS = {
    "cp": set(),
    "ln": set(),
    "install": {"-m", "-o", "-g", "-B", "-S"},
    "ditto": {"--rsrc", "--arch", "--bom"},
    "mv": set(),
    "rsync": {"--exclude", "--include", "--exclude-from", "--include-from", "--files-from",
              "-e", "--rsh", "--chmod", "--chown", "--rsync-path", "--compress-level",
              "--filter", "-f", "--out-format", "--log-file", "--bwlimit", "--timeout",
              "--partial-dir", "--backup-dir", "--suffix", "--max-size", "--min-size",
              "--block-size", "-B", "--modify-window", "-T", "--temp-dir", "--link-dest",
              "--copy-dest", "--compare-dest", "--port", "--sockopts"},
}


def _copy_operands(cmd, args):
    """The positional path operands of a copy-like command, with option values
    consumed so `rsync --exclude X src dst` does not treat X as a path."""
    value_opts = COPY_VALUE_OPTS.get(cmd, set())
    out, i = [], 0
    while i < len(args):
        a = args[i]
        if a == "--":
            out.extend(args[i + 1:])
            break
        if a.startswith("-") and a != "-":
            key = a.split("=", 1)[0]
            if key in value_opts and "=" not in a:
                i += 2
                continue
            i += 1
            continue
        out.append(a)
        i += 1
    return out


def _is_dir_dest(cmd, args, dest):
    """A copy into a directory names files inside it by their source basenames.
    Recursive (cp -R, rsync -a, ditto) or an existing directory destination, or
    a destination written with a trailing slash."""
    if dest.endswith("/"):
        return True
    recursive = any(
        _short_cluster_has(a, "R") or _short_cluster_has(a, "r") or _short_cluster_has(a, "a")
        or a in ("--recursive", "--archive")
        for a in args
    )
    d = os.path.expanduser(re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), dest.strip("'\"")))
    if recursive or cmd == "rsync" or cmd == "ditto":
        return True
    try:
        return os.path.isdir(d)
    except OSError:
        return False


def dest_write_targets(cmd, args, sources, dest):
    """The paths a copy writes: the destination, plus, when it is a directory,
    the destination joined with each source's basename (cp -R dir ~/ writes
    ~/dir)."""
    out = [dest]
    if _is_dir_dest(cmd, args, dest):
        for src in sources:
            name = os.path.basename(src.rstrip("/"))
            if name:
                out.append(dest.rstrip("/") + "/" + name)
    return out


def write_targets(toks):
    """Files a simple shell command writes."""
    if not toks:
        return []
    cmd = os.path.basename(toks[0]).lower()
    args = toks[1:]
    plain = [a for a in args if not a.startswith("-")]
    if cmd == "tee":
        return plain
    if cmd in ("sed", "gsed"):
        inplace = any(a == "-i" or a.startswith("-i") or a.startswith("--in-place") or _short_cluster_has(a, "i") for a in args)
        return plain[1:] if inplace and len(plain) >= 2 else []
    if cmd == "perl":
        if not any(_short_cluster_has(a, "i") or a.startswith("-i") for a in args):
            return []
        files, skip = [], False
        for a in args:
            if skip:
                skip = False
                continue
            if a in ("-e", "-E"):
                skip = True
                continue
            if a.startswith("-"):
                continue
            files.append(a)
        return files
    if cmd in ("cp", "mv", "ln", "install", "rsync", "ditto"):
        operands = _copy_operands(cmd, args)
        for i, a in enumerate(args):
            if a in ("-t", "--target-directory") and i + 1 < len(args):
                return [args[i + 1]]
            if a.startswith("--target-directory="):
                return [a.split("=", 1)[1]]
            if a.startswith("-t") and len(a) > 2 and not a.startswith("--"):
                return [a[2:]]
        if cmd == "ln":
            return operands
        if len(operands) < 2:
            return []
        sources, dest = operands[:-1], operands[-1]
        out = list(dest_write_targets(cmd, args, sources, dest))
        # mv removes its sources; rsync with --remove-source-files does too.
        if cmd == "mv" or (cmd == "rsync" and any(a == "--remove-source-files" for a in args)):
            out.extend(sources)
        return out
    if cmd in ("rm", "unlink", "shred"):
        # rm removes what it names: the paths are writes, and a home entry named
        # as an ancestor (rm -rf ~) is a write to everything under it.
        return plain
    if cmd in ("touch", "truncate", "mkdir", "rmdir", "mkfifo", "mknod", "mktemp"):
        return plain
    if cmd in ("chflags", "xattr"):
        # Metadata changes to a protected file are refused the same as a write.
        return plain[1:] if len(plain) >= 2 else plain
    if cmd in ("chmod", "chown"):
        return plain[1:]
    if cmd == "dd":
        return [a[3:] for a in args if a.startswith("of=")]
    if cmd in ("sort", "gsort"):
        out = []
        for i, a in enumerate(args):
            if a in ("-o", "--output") and i + 1 < len(args):
                out.append(args[i + 1])
            elif a.startswith("--output="):
                out.append(a.split("=", 1)[1])
            elif a.startswith("-o") and len(a) > 2 and not a.startswith("--"):
                out.append(a[2:])
        return out
    if cmd in ("find", "gfind"):
        out = []
        for i, a in enumerate(args):
            if a in ("-fprint", "-fprint0", "-fprintf", "-fls") and i + 1 < len(args):
                out.append(args[i + 1])
        if "-delete" in args:
            for a in args:
                if a.startswith("-") or a in ("(", "!"):
                    break
                out.append(a)
        return out
    return []


def _opt_value(args, i, names):
    """Value of args[i] when it is one of names: `--opt value`, `--opt=value`,
    or `-oVALUE` attached. Returns (value, consumed) or (None, 1)."""
    a = args[i]
    for n in names:
        if a == n:
            return (args[i + 1] if i + 1 < len(args) else None), 2
        if n.startswith("--") and a.startswith(n + "="):
            return a.split("=", 1)[1], 1
        if not n.startswith("--") and len(n) == 2 and a.startswith(n) and len(a) > 2 and not a.startswith("--"):
            return a[2:], 1
    return None, 1


def extractor_write_targets(toks):
    """Write destinations of curl -o, tar -C, unzip -d, git checkout -- / restore,
    patch -o - the tools whose write target is an option value, not a positional."""
    if not toks:
        return []
    base = os.path.basename(toks[0]).lower()
    args = toks[1:]
    out = []
    if base == "curl":
        i = 0
        while i < len(args):
            v, step = _opt_value(args, i, ("-o", "--output", "--output-dir"))
            if v is not None and (args[i] in ("-o", "--output", "--output-dir") or args[i].startswith("--output") or (args[i].startswith("-o") and len(args[i]) > 2)):
                out.append(v)
            i += step
        return out
    if base in ("tar", "gtar", "bsdtar"):
        i = 0
        while i < len(args):
            v, step = _opt_value(args, i, ("-C", "--directory"))
            if v is not None and (args[i] in ("-C", "--directory") or args[i].startswith("--directory=") or (args[i].startswith("-C") and len(args[i]) > 2)):
                out.append(v)
            i += step
        return out
    if base == "unzip":
        for i, a in enumerate(args):
            if a == "-d" and i + 1 < len(args):
                out.append(args[i + 1])
            elif a.startswith("-d") and len(a) > 2:
                out.append(a[2:])
        return out
    if base in ("7z", "7za", "7zr"):
        for a in args:
            if a.startswith("-o") and len(a) > 2:
                out.append(a[2:])
        return out
    if base in ("diff", "gdiff"):
        for a in args:
            if a.startswith("--to-file="):
                out.append(a.split("=", 1)[1])
        return out
    if base == "xcodebuild":
        opts = ("-derivedDataPath", "-resultBundlePath", "-exportPath", "-archivePath")
        i = 0
        while i < len(args):
            if args[i] in opts and i + 1 < len(args):
                out.append(args[i + 1])
                i += 2
                continue
            i += 1
        return out
    if base == "swift":
        opts = ("--scratch-path", "--build-path", "--cache-path", "--package-path")
        i = 0
        while i < len(args):
            a = args[i]
            for o in opts:
                if a == o and i + 1 < len(args):
                    out.append(args[i + 1])
                    i += 1
                elif a.startswith(o + "="):
                    out.append(a.split("=", 1)[1])
            i += 1
        return out
    if base == "patch":
        for i, a in enumerate(args):
            if a in ("-o", "--output") and i + 1 < len(args):
                out.append(args[i + 1])
            elif a.startswith("--output="):
                out.append(a.split("=", 1)[1])
        plain = [a for a in args if not a.startswith("-")]
        out.extend(plain)
        return out
    if base == "git":
        sub, rest = _git_sub(toks)
        plain = [a for a in rest if not a.startswith("-")]
        if sub == "restore":
            return plain
        if sub == "checkout" and "--" in rest:
            k = rest.index("--")
            return [a for a in rest[k + 1:] if not a.startswith("-")]
        # checkout <ref> <path...>: everything after the first plain (the ref).
        if sub == "checkout" and len(plain) >= 2:
            out.extend(plain[1:])
        if sub == "mv" and len(plain) >= 2:
            out.append(plain[-1])
        # clone / init / worktree add write into a destination directory the run
        # would then own; refusing a protected destination stops seeding .claude.
        if sub == "clone" and len(plain) >= 2:
            out.append(plain[-1])
        if sub == "init" and plain:
            out.append(plain[-1])
        if sub == "worktree" and len(plain) >= 2 and plain[0] == "add":
            out.append(plain[1])
        if sub == "read-tree" and "--prefix" in " ".join(rest):
            for a in rest:
                if a.startswith("--prefix="):
                    out.append(a.split("=", 1)[1])
        if sub == "checkout-index":
            for a in rest:
                if a.startswith("--prefix="):
                    out.append(a.split("=", 1)[1])
                elif a == "--prefix":
                    j = rest.index(a)
                    if j + 1 < len(rest):
                        out.append(rest[j + 1])
        if sub in ("apply", "am"):
            for a in rest:
                if a.startswith("--directory="):
                    out.append(a.split("=", 1)[1])
                elif a == "--directory":
                    j = rest.index(a)
                    if j + 1 < len(rest):
                        out.append(rest[j + 1])
        if sub in ("archive", "bundle"):
            for a in rest:
                if a.startswith("--output="):
                    out.append(a.split("=", 1)[1])
                elif a in ("-o", "--output"):
                    j = rest.index(a)
                    if j + 1 < len(rest):
                        out.append(rest[j + 1])
            if sub == "bundle" and plain and plain[0] == "create" and len(plain) >= 2:
                out.append(plain[1])
        names = ("--output", "--output-directory") + (("-o",) if sub == "format-patch" else ())
        i = 0
        while i < len(rest):
            v, step = _opt_value(rest, i, names)
            if v is not None:
                out.append(v)
            i += step
        return out
    return []


# --- commands -----------------------------------------------------------------

def _git_sub(toks):
    """The git subcommand and its remaining args, skipping git's global options.
    Refusal of dangerous `-c` values is handled by git_config_refusal."""
    i = 1
    while i < len(toks):
        t = toks[i]
        if t in ("-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
            i += 2
        elif t.startswith("-"):
            i += 1
        else:
            return t.lower(), toks[i + 1:]
    return "", []


# Config keys that make git run a program, load more config, or send somewhere
# else. One list for `git -c`, `git --config-env`, `git clone -c` and
# `git config`.
GIT_EXEC_CONFIG = re.compile(
    r"^(alias\..*"
    r"|core\.(hookspath|sshcommand|fsmonitor|editor|pager|askpass|gitproxy|alternaterefscommand|worktree|attributesfile|excludesfile)"
    r"|sequence\.editor|diff\.external|diff\..*\.(command|textconv)|difftool\..*|mergetool\..*|merge\..*\.driver"
    r"|filter\..*|credential\..*|gpg\..*|url\..*\.(insteadof|pushinsteadof)|include\..*|includeif\..*"
    r"|http\..*|remote\..*|branch\..*\.(remote|pushremote)|protocol\..*|pager\..*|interactive\.difffilter"
    r"|uploadpack\..*|receive\..*|ssh\.variant|init\.templatedir|submodule\..*|trailer\..*|hook\..*"
    r"|man\..*|browser\..*|web\.browser|instaweb\..*|sendemail\..*|safe\.directory|color\.pager)$",
    re.I,
)


def _git_exec_key(val):
    key = val.split("=", 1)[0]
    return key if GIT_EXEC_CONFIG.match(key) else None


def git_config_refusal(toks):
    """git -c KEY=VALUE / --config-env=KEY=ENV with a key that runs a program
    or redirects git, before the subcommand."""
    i = 1
    while i < len(toks):
        t = toks[i]
        val = None
        if t == "-c" and i + 1 < len(toks):
            val = toks[i + 1]
            i += 2
        elif t.startswith("-c") and len(t) > 2:
            val = t[2:]
            i += 1
        elif t == "--config-env" and i + 1 < len(toks):
            val = toks[i + 1]
            i += 2
        elif t.startswith("--config-env="):
            val = t.split("=", 1)[1]
            i += 1
        else:
            if t.startswith("-"):
                i += 1
                if t in ("-C", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
                    i += 1
                continue
            break
        if val is None:
            continue
        key = _git_exec_key(val)
        if key:
            return f"git -c {key} reconfigures git for the whole run"
    return None


GIT_CONFIG_READS = {"--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "-l", "--show-origin",
                    "--show-scope", "--name-only", "get", "list"}
GIT_CONFIG_WRITE_SUBS = {"set", "unset", "rename-section", "remove-section"}
GIT_CONFIG_WRITE_OPTS = {"--unset", "--unset-all", "--add", "--replace-all", "--rename-section", "--remove-section"}


def _git_config(rest):
    if any(a in ("-e", "--edit", "edit") for a in rest):
        return "git config --edit opens an editor on the config"
    if any(a in GIT_CONFIG_READS for a in rest):
        return None
    keys = [a for a in rest if not a.startswith("-")]
    writes = bool(keys and keys[0] in GIT_CONFIG_WRITE_SUBS)
    if writes:
        keys = keys[1:]
    writes = writes or len(keys) >= 2 or any(a in GIT_CONFIG_WRITE_OPTS for a in rest)
    if not writes:
        return None
    if any(a in ("--global", "--system") for a in rest):
        return "git config --global / --system writes this machine's git configuration"
    for k in keys[:2]:
        if GIT_EXEC_CONFIG.match(k):
            return f"git config {k} changes what git runs or where it sends"
    return None


# git subcommands that write to a remote or another version-control system.
GIT_OUTWARD_SUBS = {
    "send-pack": "git send-pack writes to a remote",
    "http-push": "git http-push writes to a remote",
    "upload-archive": "git upload-archive serves this repository to a remote",
    "svn": "git svn writes to a Subversion remote",
    "p4": "git p4 writes to a Perforce remote",
    "imap-send": "git imap-send sends mail",
    "request-pull": "git request-pull is a publish step",
    "web--browse": "git web--browse opens a browser on a URL",
    "instaweb": "git instaweb starts a web server and opens a browser",
    "daemon": "git daemon serves this repository to the network",
}
# Global flags before the subcommand that move where git reads or writes, or
# what it runs. --git-dir / --work-tree / --exec-path / --namespace are refused;
# -C and -c are handled elsewhere (-c by git_config_refusal).
GIT_GLOBAL_REFUSED = ("--git-dir", "--work-tree", "--exec-path", "--namespace")


def git_global_flag_refusal(toks):
    """A git global flag before the subcommand that redirects git's directories
    or its helper path."""
    for t in toks[1:]:
        if not t.startswith("-"):
            break
        key = t.split("=", 1)[0]
        if key in GIT_GLOBAL_REFUSED:
            return f"git {key} redirects where git reads, writes or finds its programs"
    return None


def git_credential_refusal(sub, rest):
    """git credential / credential-<helper>: reading or writing the store."""
    if sub == "credential" or sub.startswith("credential-"):
        return f"git {sub} reads or writes the credential store"
    if sub == "lfs" and rest and rest[0] == "push":
        return "git lfs push writes to a remote"
    return None


def git_exec_refusal(sub, rest, toks):
    """git subcommands and options that run a command line or a transport the
    guard cannot see."""
    if any(t.startswith(("ext::", "fd::")) for t in toks):
        return "an ext:: or fd:: git transport runs a command"
    if sub in GIT_OUTWARD_SUBS:
        return GIT_OUTWARD_SUBS[sub]
    r = git_credential_refusal(sub, rest)
    if r:
        return r
    if sub == "rebase":
        for a in rest:
            if a in ("--exec", "-x") or a.startswith("--exec=") or _short_cluster_has(a, "x"):
                return "git rebase --exec runs a command after each commit"
    if sub == "bisect" and rest and rest[0] == "run":
        return "git bisect run runs a command"
    if sub == "submodule" and "foreach" in rest:
        return "git submodule foreach runs a command in each submodule"
    if sub == "difftool":
        for a in rest:
            if a in ("-x", "--extcmd") or a.startswith("--extcmd=") or _short_cluster_has(a, "x"):
                return "git difftool --extcmd runs a command"
    if sub in ("filter-branch", "filter-repo"):
        return f"git {sub} runs filter commands"
    if sub == "grep":
        for a in rest:
            if a.startswith("-O") or a.startswith("--open-files-in-pager"):
                return "git grep -O runs a pager command"
    if sub == "maintenance" and rest and rest[0] in ("start", "register"):
        return f"git maintenance {rest[0]} installs a background job that runs git"
    if sub == "help" and any(a in ("-w", "--web") for a in rest):
        return "git help -w opens a browser"
    for a in rest:
        key = a.split("=", 1)[0]
        if key in ("--upload-pack", "--receive-pack", "--exec", "--template") or (sub == "clone" and a == "-u"):
            return f"git {sub} {key} runs a program or installs hooks"
    if sub == "clone":
        for i, a in enumerate(rest):
            val = None
            if a in ("-c", "--config") and i + 1 < len(rest):
                val = rest[i + 1]
            elif a.startswith("--config="):
                val = a.split("=", 1)[1]
            if val and _git_exec_key(val):
                return f"git clone -c {_git_exec_key(val)} reconfigures the new repository"
    return None


# git subcommands that reach a remote host: the URL argument (or the configured
# remote) must be on the allowlist, the same rule curl and gh follow.
GIT_NETWORK_SUBS = {"fetch", "clone", "pull", "ls-remote", "archive", "bundle", "remote-https", "remote-http"}


def git_url_refusal(sub, rest, toks, policy, env):
    """A git subcommand that contacts a remote, checked against the network
    allowlist. A literal URL argument is host-checked; a bare remote name or no
    argument (fetch origin, pull) is left to the configured remote, which the
    guard does not resolve, so it passes here and the sandbox confines it."""
    urls = []
    if sub in GIT_NETWORK_SUBS:
        for a in rest:
            if "://" in a or a.startswith("git@") or re.match(r"^[a-z]+::", a):
                urls.append(a)
    if sub == "submodule" and rest and rest[0] == "add":
        for a in rest[1:]:
            if "://" in a or a.startswith("git@"):
                urls.append(a)
    if sub == "remote" and rest and rest[0] in ("add", "set-url"):
        for a in rest[1:]:
            if "://" in a or a.startswith("git@"):
                urls.append(a)
    for a in rest:
        if a.startswith("--remote="):
            v = a.split("=", 1)[1]
            if "://" in v or v.startswith("git@"):
                urls.append(v)
    for u in urls:
        if u.startswith("git@") or re.match(r"^[a-z]+::", u):
            return f"git {sub} over ssh or a custom transport ({u[:40]}) cannot be host-checked; refused unattended"
        r = check_url_host(u, policy, env, f"git {sub}")
        if r:
            return r
    return None


GH_WRITES = {
    "pr": {"create", "merge", "ready", "edit", "comment", "review", "close", "reopen", "lock", "unlock"},
    "issue": {"create", "close", "edit", "comment", "reopen", "delete", "transfer", "lock", "unlock", "pin", "unpin", "develop"},
    "release": {"create", "edit", "delete", "upload", "delete-asset"},
    "repo": {"create", "edit", "delete", "fork", "rename", "archive", "unarchive", "sync", "deploy-key", "set-default"},
    "workflow": {"run", "enable", "disable"},
    "run": {"rerun", "cancel", "delete"},
    "label": {"create", "edit", "delete", "clone"},
    "gist": {"create", "edit", "delete", "clone"},
    "secret": None,
    "variable": None,
    "ruleset": None,
    "alias": None,
    "ssh-key": None,
    "gpg-key": None,
    "extension": None,
    "codespace": None,
    "cache": {"delete"},
    "project": {"create", "edit", "delete", "close", "copy", "field-create", "field-delete", "item-add", "item-archive", "item-create", "item-delete", "item-edit", "link", "unlink", "mark-template"},
}
GH_API_FIELD_FLAGS = {"-f", "-F", "--field", "--raw-field", "--input"}


def _is_field_flag(t):
    if t in GH_API_FIELD_FLAGS:
        return True
    if any(t.startswith(f + "=") for f in GH_API_FIELD_FLAGS if f.startswith("--")):
        return True
    # attached short forms: -ftitle=x, -Fbody=@f
    return bool(re.match(r"^-[fF].", t))


# gh's built-in command aliases, so `gh cs` is judged as `gh codespace`.
GH_ALIASES = {
    "cs": "codespace", "rs": "release", "ext": "extension", "co": "pr",
    "pr": "pr", "i": "issue", "r": "repo", "w": "workflow",
}
# gh config keys that hold or point at a credential; reading one prints a secret.
GH_TOKEN_KEYS = {"oauth_token", "token"}


def _gh(toks, policy=None, env=None):
    args = toks[1:]
    # Capture -R/--repo (dropped) and --hostname (dropped from the verb scan but
    # host-checked). A --hostname off the allowlist is an exfiltration target.
    cleaned, hostname, i = [], None, 0
    while i < len(args):
        a = args[i]
        if a in ("-R", "--repo"):
            i += 2
            continue
        if a.startswith("--repo="):
            i += 1
            continue
        if a == "--hostname" and i + 1 < len(args):
            hostname = args[i + 1]
            i += 2
            continue
        if a.startswith("--hostname="):
            hostname = a.split("=", 1)[1]
            i += 1
            continue
        cleaned.append(a)
        i += 1
    if hostname and policy is not None:
        r = check_url_host(hostname, policy, env, "gh --hostname")
        if r:
            return r
    plains = [a for a in cleaned if not a.startswith("-")]
    if not plains:
        return None
    group = GH_ALIASES.get(plains[0], plains[0])
    if group == "api":
        return _gh_api(cleaned[cleaned.index(plains[0]) + 1:], policy, env)
    if group == "auth":
        sub = plains[1] if len(plains) > 1 else ""
        if sub == "token" or "--show-token" in cleaned or "-t" in cleaned:
            return "gh auth token prints a credential"
        if sub == "status":
            return None
        return f"gh auth {sub}".rstrip() + " reads or changes this machine's GitHub credentials"
    if group == "config":
        sub = plains[1] if len(plains) > 1 else ""
        if sub == "set":
            return "gh config set changes the GitHub CLI's configuration"
        if sub == "get":
            if any(k in GH_TOKEN_KEYS for k in plains[2:]):
                return "gh config get of a token prints a credential"
        return None
    if group not in GH_WRITES:
        return None
    verbs = GH_WRITES[group]
    sub = plains[1] if len(plains) > 1 else ""
    if verbs is None or sub in verbs:
        return f"gh {group} {sub}".strip() + " writes to GitHub; the runner is the only writer"
    return None


def _gh_api(rest, policy=None, env=None):
    method = "GET"
    fields = False
    endpoint = ""
    field_values = []
    i = 0
    while i < len(rest):
        t = rest[i]
        if t in ("-X", "--method"):
            method = (rest[i + 1] if i + 1 < len(rest) else "").upper()
            i += 2
            continue
        if t.startswith("--method="):
            method = t.split("=", 1)[1].upper()
        elif t.startswith("-X") and len(t) > 2:
            method = t[2:].upper()
        elif _is_field_flag(t):
            fields = True
            if t in GH_API_FIELD_FLAGS:
                if i + 1 < len(rest):
                    field_values.append(rest[i + 1])
                i += 2
                continue
            field_values.append(t.split("=", 1)[1] if "=" in t else t[2:])
        elif not t.startswith("-") and not endpoint:
            endpoint = t
        i += 1
    # An endpoint given as a full URL goes to whatever host it names, not
    # api.github.com; host-check it.
    if "://" in endpoint and policy is not None:
        r = check_url_host(endpoint, policy, env, "gh api")
        if r:
            return r
    if endpoint == "graphql":
        joined = " ".join(rest).lower()
        if any(v.startswith("@") or "=@" in v for v in field_values):
            return "gh api graphql with an @file field reads a file into the mutation the guard cannot see"
        if re.search(r"\bmutation\b", joined) or "--input" in rest:
            return "gh api graphql mutation writes to GitHub"
        return None
    explicit = any(t in ("-X", "--method") or t.startswith("--method=") or (t.startswith("-X") and len(t) > 2) for t in rest)
    if method not in ("GET", "HEAD") or (fields and not explicit):
        return f"gh api with method {method if explicit else 'POST (fields imply it)'} writes to GitHub"
    return None


CURL_VALUE_FLAGS = {"-o", "--output", "-H", "--header", "-u", "--user", "-A", "--user-agent", "-e", "--referer",
                    "-X", "--request", "-m", "--max-time", "--connect-timeout", "-w", "--write-out", "-K", "--config",
                    "-x", "--proxy", "-b", "--cookie", "-c", "--cookie-jar", "--retry", "-r", "--range", "--cacert",
                    "--cert", "--key", "-E", "--resolve", "--connect-to", "-O", "--output-dir", "--limit-rate", "-Y", "-y",
                    "--unix-socket", "--doh-url"}
CURL_REFUSED_FLAGS = {"-x", "--proxy", "--connect-to", "--resolve", "-K", "--config", "--unix-socket", "--doh-url"}
WGET_WRITE_FLAGS = {"--post-data", "--post-file", "--body-data", "--body-file", "--method"}
CURL_BODY_LONG = {"--data", "--data-raw", "--data-binary", "--data-urlencode", "--data-ascii", "--json", "--form", "--form-string", "--upload-file"}


def _fetch(toks, policy, env=None):
    tool = os.path.basename(toks[0]).lower()
    args = toks[1:]
    if tool == "curl":
        method = "GET"
        for a in args:
            if a in CURL_REFUSED_FLAGS or a.split("=", 1)[0] in CURL_REFUSED_FLAGS:
                return f"curl {a.split('=', 1)[0]} redirects the connection somewhere the allowlist cannot check"
        for i, t in enumerate(args):
            if t in ("-X", "--request") and i + 1 < len(args):
                method = args[i + 1].upper()
            elif t.startswith("-X") and len(t) > 2 and not t.startswith("--"):
                method = t[2:].upper()
            elif t.startswith("--request="):
                method = t.split("=", 1)[1].upper()
            body = t in CURL_BODY_LONG or any(t.startswith(f + "=") for f in CURL_BODY_LONG)
            if not body and t.startswith("-") and not t.startswith("--"):
                # short cluster: -d, -F, -T, and attached forms -d@file, -Tfile
                if any(ch in t[1:] for ch in ("d", "F", "T")):
                    body = True
            if body:
                return "curl with a request body or upload writes to a remote service; the runner is the only writer"
        if method not in ("GET", "HEAD"):
            return f"curl -X {method} writes to a remote service; the runner is the only writer"
        urls, skip = [], False
        for i, t in enumerate(args):
            if skip:
                skip = False
                continue
            if t == "--url" and i + 1 < len(args):
                urls.append(args[i + 1])
                skip = True
                continue
            if t in CURL_VALUE_FLAGS:
                skip = True
                continue
            if not t.startswith("-"):
                urls.append(t)
    else:
        if any(t in WGET_WRITE_FLAGS or t.split("=", 1)[0] in WGET_WRITE_FLAGS for t in args):
            return "wget with a request body writes to a remote service; the runner is the only writer"
        urls = [t for t in args if not t.startswith("-")]
    if not urls:
        return f"{tool} with no literal URL: the host cannot be checked against the allowlist"
    for u in urls:
        r = check_url_host(u, policy, env, tool)
        if r:
            return r
    return None


def _hosts(policy, env=None):
    hosts = set(h.lower() for h in policy.get("network", {}).get("staticHosts", []))
    env = os.environ if env is None else env
    snap = env.get("MA_UNATTENDED_HOSTS")
    if snap:
        for h in snap.split(","):
            if h.strip():
                hosts.add(h.strip().lower())
        return hosts
    prefs = env.get("MULTI_AGENT_PREFS") or os.path.join(home(), ".claude", "multi-agent-preferences.json")
    try:
        with open(prefs, encoding="utf-8") as f:
            configured = ((json.load(f).get("global") or {}).get("hosts") or {})
        for key in policy.get("network", {}).get("prefsHostKeys", []):
            v = configured.get(key)
            if isinstance(v, str) and v.strip():
                hosts.add(re.sub(r"^[a-z]+://", "", v.strip().lower()).split("/")[0].split(":")[0])
    except (OSError, ValueError, AttributeError):
        pass
    return hosts


def _host_of(url):
    if "$" in url or "`" in url:
        return None
    try:
        parts = urlsplit(url if "://" in url else "//" + url, scheme="https")
    except ValueError:
        return None
    host = parts.hostname
    return host.lower() if host else None


def _allowed_host(host, allowed):
    return any(host == a or host.endswith("." + a) for a in allowed)


def check_url_host(url, policy, env=None, tool="fetch"):
    if "$" in url or "`" in url:
        return f"{tool} to a URL built at run time: the host cannot be checked against the allowlist"
    host = _host_of(url)
    if not host:
        return f"{tool} target '{url[:60]}' is not a URL the allowlist can check"
    if not _allowed_host(host, _hosts(policy, env)):
        return f"{tool} to {host}: not in the unattended network allowlist (unattended-policy.json + prefs.global.hosts)"
    return None


# Tools that open a connection to a host the allowlist cannot check (an
# arbitrary host, a raw socket, a name lookup that can carry data in the query).
# Refused outright unattended; documented as residual in unattended-security.md.
NETWORK_TOOLS = {
    "ssh": "ssh opens a connection to an arbitrary host and can run a command there",
    "scp": "scp copies over ssh to an arbitrary host",
    "sftp": "sftp transfers over ssh to an arbitrary host",
    "rsh": "rsh runs a command on a remote host",
    "telnet": "telnet opens a raw connection to an arbitrary host",
    "nc": "nc opens a raw socket the allowlist cannot check",
    "ncat": "ncat opens a raw socket the allowlist cannot check",
    "netcat": "netcat opens a raw socket the allowlist cannot check",
    "socat": "socat opens a raw socket the allowlist cannot check",
    "dig": "dig resolves a name and can carry data in the query",
    "nslookup": "nslookup resolves a name and can carry data in the query",
    "host": "host resolves a name and can carry data in the query",
    "drill": "drill resolves a name and can carry data in the query",
    "ping": "ping reaches an arbitrary host",
    "ping6": "ping6 reaches an arbitrary host",
    "aria2c": "aria2c downloads from an arbitrary host",
    "http": "httpie reaches an arbitrary host",
    "https": "httpie reaches an arbitrary host",
    "httpie": "httpie reaches an arbitrary host",
    "xh": "xh reaches an arbitrary host",
}


def network_tool_refusal(base):
    return NETWORK_TOOLS.get(base)


# --- installs -----------------------------------------------------------------

_GIT_CACHE = {}


def _changed_files(cwd):
    key = os.path.realpath(cwd or ".")
    if key in _GIT_CACHE:
        return _GIT_CACHE[key]
    try:
        import subprocess

        out = subprocess.run(["git", "-C", key, "diff", "--name-only", "HEAD"],
                             capture_output=True, text=True, timeout=3)
        val = None if out.returncode != 0 else {os.path.basename(l.strip()) for l in out.stdout.splitlines() if l.strip()}
    except Exception:
        val = None
    _GIT_CACHE[key] = val
    return val


def _manifest_changed(names, cwd):
    changed = _changed_files(cwd)
    if changed is None:
        return True
    return bool(changed & set(names))


NODE_LOCKS = ["package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"]


def _local_bin(name, cwd):
    """node_modules/.bin/<name> in cwd or an ancestor, the way npx looks."""
    d = os.path.realpath(cwd or ".")
    while True:
        if os.path.exists(os.path.join(d, "node_modules", ".bin", name)):
            return True
        parent = os.path.dirname(d)
        if parent == d:
            return False
        d = parent


def _npx(tool, args, cwd):
    """npx / npm exec run a binary; when it is not already installed they
    fetch the package, and with no TTY they do it without asking. Only a
    binary present in node_modules/.bin passes."""
    spec = ""
    i = 0
    while i < len(args):
        a = args[i]
        if a in ("-y", "--yes") or a.startswith("--package") or a == "-p":
            return f"{tool} --yes / --package fetches a package that is not in the lockfile"
        if a in ("-c", "--call") or a.startswith("--call="):
            return f"{tool} --call runs a shell command the guard cannot see"
        if a == "--":
            spec = args[i + 1] if i + 1 < len(args) else ""
            break
        if not a.startswith("-"):
            spec = a
            break
        i += 1
    if not spec:
        return None
    name = spec[1:] if spec.startswith("@") else spec
    if "@" in name or ":" in name or name.startswith((".", "/")) and not spec.startswith("@"):
        return f"{tool} {spec[:40]} names a version, path or URL: it fetches or runs a package outside node_modules/.bin"
    if not _local_bin(spec.rsplit("/", 1)[-1], cwd):
        return f"{tool} {spec[:40]}: not installed in node_modules/.bin, so it would fetch the package"
    return None


def _install(toks, cwd):
    tool = os.path.basename(toks[0]).lower()
    args = toks[1:]
    plain = [a for a in args if not a.startswith("-")]
    sub = plain[0] if plain else ""
    if tool in ("pnpx", "bunx", "uvx") or (tool in ("pnpm", "yarn") and sub == "dlx") or (tool == "bun" and sub == "x"):
        return f"{tool} {sub if tool in ('pnpm', 'yarn', 'bun') else ''}".rstrip() + " fetches and runs a package"
    if tool == "yarn" and sub == "global":
        return "yarn global changes this machine"
    if tool == "uv" and sub == "tool" and len(plain) > 1 and plain[1] in ("run", "install", "upgrade"):
        return f"uv tool {plain[1]} fetches a package"
    if tool == "pipx" and sub in ("run", "install", "inject", "upgrade", "reinstall", "install-all"):
        return f"pipx {sub} fetches a package"
    if tool == "npx" or (tool == "npm" and sub in ("exec", "x")):
        return _npx(tool, args if tool == "npx" else args[args.index(sub) + 1:], cwd)
    if tool in ("npm", "pnpm", "yarn", "bun"):
        adds = {"npm": ("install", "i", "in", "add", "isntall", "install-test", "it", "install-ci-test", "cit"),
                "pnpm": ("install", "i", "add"), "yarn": ("add",), "bun": ("install", "i", "add")}[tool]
        if sub in adds and (len(plain) > 1 or sub == "add"):
            return f"{tool} {sub} {plain[1] if len(plain) > 1 else ''}".rstrip() + " adds a package"
        if sub in ("update", "upgrade", "up", "dedupe", "upgrade-interactive"):
            return f"{tool} {sub} rewrites the lockfile"
        if "-g" in args or "--global" in args:
            return f"{tool} global install changes this machine"
        restores = sub in ("install", "i", "ci", "install-test", "it", "install-ci-test", "cit") or (tool == "yarn" and sub == "")
        if restores and _manifest_changed(NODE_LOCKS, cwd):
            return f"{tool} install after the run changed package.json or a lockfile"
        return None
    if tool in ("pip", "pip3") or (tool.startswith("python") and "-m" in args and "pip" in args):
        rest = args[args.index("pip") + 1:] if "pip" in args and tool.startswith("python") else args
        if rest and rest[0] == "install":
            reqs, pkgs, skip = [], [], False
            for i, a in enumerate(rest[1:]):
                if skip:
                    skip = False
                    continue
                if a in ("-r", "--requirement", "-c", "--constraint"):
                    if i + 2 <= len(rest[1:]):
                        reqs.append(rest[1:][i + 1])
                    skip = True
                    continue
                if a in ("-e", "--editable"):
                    skip = True
                    continue
                if not a.startswith("-") and a not in (".",):
                    pkgs.append(a)
            if pkgs:
                return f"pip install {pkgs[0]} adds a package"
            if reqs and _manifest_changed([os.path.basename(r) for r in reqs] + ["pyproject.toml"], cwd):
                return "pip install -r after the run changed the requirements file"
        return None
    if tool in ("uv", "poetry", "pdm", "pipenv"):
        if sub == "add" or (tool == "uv" and sub == "pip" and len(plain) > 1 and plain[1] == "install") or (tool == "pipenv" and sub == "install" and len(plain) > 1):
            return f"{tool} {sub} adds a package"
        return None
    if tool == "pod":
        if sub == "update":
            return "pod update rewrites Podfile.lock"
        if sub == "install" and _manifest_changed(["Podfile", "Podfile.lock"], cwd):
            return "pod install after the run changed the Podfile"
        return None
    if tool == "gem" and sub == "install":
        return "gem install adds a package"
    if tool == "bundle" and sub in ("add", "update"):
        return f"bundle {sub} changes the Gemfile"
    if tool == "brew" and sub in ("install", "reinstall", "tap", "upgrade"):
        return f"brew {sub} changes this machine"
    if tool == "cargo" and sub in ("add", "install", "update"):
        return f"cargo {sub} adds or updates a package"
    if tool == "go" and sub == "get":
        return "go get adds a module"
    if tool == "go" and sub == "install" and any("@" in a for a in plain[1:]):
        return "go install <pkg>@<version> fetches a module"
    if tool == "swift" and sub == "package" and len(plain) > 1 and plain[1] in ("add-dependency", "update"):
        return f"swift package {plain[1]} changes Package.resolved"
    return None


SECURITY_READS = re.compile(r"^(find-[a-z-]+|dump-keychain|export|dump-trust-settings|add-[a-z-]+password|set-[a-z-]+|delete-[a-z-]+password|import)$")


WEB_SCHEMES = ("http", "https", "ws", "wss", "ftp")


def _web_url_refusal(url, policy, env, tool):
    """Host-check a URL that a browser would load; an app's own deep-link
    scheme opens the app under test and passes."""
    m = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", url)
    scheme = m.group(1).lower() if m else ""
    if scheme and scheme not in WEB_SCHEMES and "://" in url:
        return None
    return check_url_host(url, policy, env, tool)


LAUNCHCTL_READS = {"list", "print", "print-cache", "print-disabled", "blame", "help", "version", "error", "examine"}
DEFAULTS_READS = {"read", "read-type", "domains", "find", "help"}


def host_command_refusal(base, toks, policy, env):
    """Commands that change this machine or send outward without a write
    target the guard models: persistent jobs, preferences, distribution
    uploads, and URLs opened in a browser."""
    plains = [a for a in toks[1:] if not a.startswith("-")]
    if base == "crontab" and toks[1:] != ["-l"]:
        return "crontab installs a job that runs as the operator"
    if base == "launchctl" and not (plains and plains[0] in LAUNCHCTL_READS):
        return "launchctl changes which jobs this machine runs"
    if base == "defaults" and not (plains and plains[0] in DEFAULTS_READS):
        return "defaults writes this machine's preferences"
    if base in ("altool", "notarytool"):
        return f"{base} uploads to Apple or stores a credential"
    if base == "simctl" and plains and plains[0] == "openurl":
        return _web_url_refusal(plains[-1], policy, env, "simctl openurl") if len(plains) >= 3 else None
    if base == "open":
        for a in plains:
            if "://" not in a or not re.match(r"^(https?)://", a, re.I):
                return "open launches an application or a file the guard cannot judge"
            r = check_url_host(a, policy, env, "open")
            if r:
                return r
        return None if plains else "open with no URL cannot be judged"
    return None


def _publisher(toks, policy):
    scripts = policy.get("publisherScripts", {})
    for i, t in enumerate(toks[:3]):
        base = os.path.basename(t)
        if base in scripts and not base.startswith("$"):
            subs = scripts[base]
            rest = [a for a in toks[i + 1:] if not a.startswith("-")]
            if subs is None or (rest and rest[0] in subs):
                return f"{base} publishes outward, reads a credential or changes device access; an unattended run may not"
            if rest and (_has_ph(rest[0]) or rest[0].startswith("$")):
                return f"{base} with a subcommand built at run time cannot be judged"
    return None


# --- variables, cwd and write targets -----------------------------------------

VAR_RX = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")


def expand(text, variables, env=None):
    """text with $NAME / ${NAME} expanded from the variables this command set,
    then HOME, then the hook's environment. None when any part cannot be
    resolved: a substitution placeholder, an unknown variable, or a `$` form
    other than a plain name."""
    if _has_ph(text):
        return None
    env = os.environ if env is None else env
    unresolved = False

    def sub(m):
        nonlocal unresolved
        name = m.group(1) or m.group(2)
        if name in variables:
            v = variables[name]
        elif name == "HOME":
            v = home()
        else:
            v = env.get(name)
        if v is None:
            unresolved = True
            return ""
        return v

    out = VAR_RX.sub(sub, text)
    if unresolved or "$" in out:
        return None
    return out


class Ctx:
    """Per-command state: the cwd after any cd (None once it cannot be
    resolved), and the variables set by earlier simple assignments."""

    def __init__(self, cwd, policy, env, variables=None, inners=None):
        self.cwd = cwd
        self.policy = policy
        self.env = env
        self.vars = dict(variables or {})
        self.inners = inners or []
        # Set per segment: whether the segment has a glob character outside
        # quotes, so a globbed write target can be told from a quoted one.
        self.unquoted_glob = False

    def target(self, raw):
        """Reason a write target is refused, or None."""
        path = raw.strip("'\"")
        if path in ("/dev/null", "/dev/stderr", "/dev/stdout") or path.startswith("&"):
            return None
        if self.unquoted_glob and any(ch in path for ch in GLOB_CHARS):
            return f"write target '{path[:60]}' is a glob pattern the shell expands to paths the guard cannot see"
        e = expand(path, self.vars, self.env)
        if e is None:
            return f"write target '{path[:60]}' is built at run time and cannot be checked"
        e = os.path.expanduser(e)
        if self.cwd is None and not os.path.isabs(e):
            return f"write target '{path[:60]}' is relative to a directory the guard could not resolve"
        return check_path(e, self.cwd, self.policy, self.env)


def check_segment(seg, ctx):
    policy, env = ctx.policy, ctx.env
    r = dangerous_assignment(tokenize(seg))
    if r:
        return r
    toks = _strip_wrappers(tokenize(seg))
    if not toks:
        return None
    if "$" in toks[0]:
        e = expand(toks[0], ctx.vars, env)
        if e is None:
            return "the command name is built at run time and cannot be judged"
        toks = [e] + toks[1:]
    base = os.path.basename(toks[0]).lower()

    def targets(ts):
        for t in ts:
            r = ctx.target(t)
            if r:
                return r
        return None

    if base == "git":
        r = git_config_refusal(toks) or git_global_flag_refusal(toks)
        if r:
            return r
        sub, rest = _git_sub(toks)
        if _has_ph(sub) or sub.startswith("$"):
            return "a git subcommand built at run time cannot be judged"
        r = git_exec_refusal(sub, rest, toks)
        if r:
            return r
        r = git_url_refusal(sub, rest, toks, policy, env)
        if r:
            return r
        # git apply / am write files a diff names, which the guard cannot
        # enumerate. The run's own work is committed, not applied from a patch;
        # refused unattended, and the sandbox confines the paths if it ran.
        if sub in ("apply", "am") and not any(
            a.startswith("--directory") for a in rest
        ):
            return f"git {sub} applies a diff that names files the guard cannot enumerate; refused unattended"
        if sub == "push":
            return "git push: the runner pushes after it verifies pr-request.json"
        if sub == "send-email":
            return "git send-email sends mail"
        if sub == "remote" and rest and rest[0] in ("add", "set-url", "rename", "remove", "rm", "set-head"):
            return f"git remote {rest[0]} changes where the runner would push"
        if sub == "config":
            r = _git_config(rest)
            if r:
                return r
        return targets(extractor_write_targets(toks))
    if base == "gh":
        plains = [a for a in toks[1:] if not a.startswith("-")]
        if any(_has_ph(a) or a.startswith("$") for a in plains[:2]):
            return "a gh command group or verb built at run time cannot be judged"
        return _gh(toks, policy, env)
    if base == "security" and len(toks) > 1:
        j = 1
        while j < len(toks) and toks[j].startswith("-"):
            j += 2 if toks[j] == "-p" else 1
        sub = toks[j] if j < len(toks) else ""
        if SECURITY_READS.match(sub):
            return f"security {sub} reads or writes the Keychain"
        if _has_ph(sub) or sub.startswith("$"):
            return "a security subcommand built at run time cannot be judged"
    r = network_tool_refusal(base)
    if r:
        return r
    if base == "rsync":
        # rsync src host:path (or host::module) transfers to a remote the
        # allowlist cannot check; a local `-e` remote shell is the same.
        for a in toks[1:]:
            if a.startswith("-"):
                continue
            if re.match(r"^[A-Za-z0-9_.-]+@?[A-Za-z0-9_.-]*:", a) and not os.path.isabs(a) and "://" not in a:
                return "rsync to a remote host cannot be checked against the allowlist; refused unattended"
        if any(a in ("-e", "--rsh") or a.startswith("--rsh=") or a.startswith("-e") and len(a) > 2 for a in toks[1:]):
            return "rsync -e runs a remote shell the guard cannot see"
    r = host_command_refusal(base, toks, policy, env)
    if r:
        return r
    if base in ("curl", "wget"):
        r = _fetch(toks, policy, env)
        if r:
            return r
        return targets(extractor_write_targets(toks))
    if base == "patch":
        return "patch applies a diff that names files the guard cannot enumerate; refused unattended"
    if base in ("tar", "gtar", "bsdtar"):
        # An archive extraction whose members carry absolute paths (-P) writes
        # anywhere; without -P the paths are under the extraction directory,
        # which -C names and check_path judges (the sandbox confines the rest).
        if any(_short_cluster_has(a, "P") or a == "--absolute-names" for a in toks[1:]):
            return "tar -P / --absolute-names extracts to absolute paths the guard cannot enumerate"
        r = targets(extractor_write_targets(toks))
        if r:
            return r
    if base in ("unzip", "cpio", "pax", "7z", "7za", "diff", "gdiff", "xcodebuild"):
        r = targets(extractor_write_targets(toks))
        if r:
            return r
    if base == "swift":
        r = targets(extractor_write_targets(toks))
        if r:
            return r
    plains = [a for a in toks[1:] if not a.startswith("-")]
    if base in ("npm", "pnpm", "yarn", "bun", "npx", "pip", "pip3", "uv", "poetry", "pdm", "pipenv", "pod", "gem",
                "bundle", "brew", "cargo", "go", "swift") and plains and _has_ph(plains[0]):
        return f"{base} with a subcommand built at run time cannot be judged"
    r = _install(toks, ctx.cwd or os.getcwd())
    if r:
        return r
    r = _publisher(toks, policy)
    if r:
        return r
    return targets(write_targets(toks))


REDIRECT = re.compile(r"(?:^|[^0-9&<>])(?:\d?>>?|&>>?|>\|)\s*([^\s;&|<>]+)")
ASSIGN_RX = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", re.S)


def _cd(stripped, ctx):
    """New cwd after cd/pushd/popd: a resolved absolute path, or None."""
    cmd = os.path.basename(stripped[0]).lower()
    if cmd == "popd":
        return None
    # `cd -` returns to $OLDPWD, which the guard does not track: the new cwd is
    # unknown, so anything after it is judged with no directory (and refused if
    # it needs one). Caught before the flag filter drops the "-".
    if any(t == "-" for t in stripped[1:]):
        return None
    args = [t for t in stripped[1:] if not t.startswith("-")]
    if not args:
        return home() if cmd == "cd" else None
    e = expand(args[0], ctx.vars, ctx.env)
    if e is None or any(ch in e for ch in GLOB_CHARS):
        return None
    e = os.path.expanduser(e)
    if not os.path.isabs(e):
        # With CDPATH set, `cd name` may land in a CDPATH entry instead.
        cdpath = (os.environ if ctx.env is None else ctx.env).get("CDPATH")
        if cdpath and not (e == "." or e == ".." or e.startswith("./") or e.startswith("../")):
            return None
        if ctx.cwd is None:
            return None
        e = os.path.join(ctx.cwd, e)
    return os.path.normpath(e)


def check_bash(cmd, cwd, policy, env=None, variables=None, nested=False):
    try:
        if not nested:
            cmd = strip_heredocs(cmd)
        outer, inners = extract_substitutions(cmd)
    except Refused as e:
        return str(e)
    if nested and inners:
        return "a command substitution nested inside another is not analysed"
    r = structural_refusal(outer)
    if r:
        return r
    ctx = Ctx(cwd or os.getcwd(), policy, env, variables, inners)
    for seg in split_ops(outer):
        # Every substitution in this segment runs first, in the segment's cwd,
        # and must pass the same checks as if it were typed directly.
        for m in PLACEHOLDER_RX.finditer(seg):
            inner = inners[int(m.group(1))]
            if not inner.strip():
                return "an empty command substitution"
            if ctx.cwd is None:
                return "a command substitution after a cd the guard could not resolve"
            r = check_bash(inner, ctx.cwd, policy, env, ctx.vars, nested=True)
            if r:
                return f"inside $(...): {r}"
        toks = tokenize(seg)
        ctx.unquoted_glob = any(ch in _strip_quotes(seg, keep_double=False) for ch in GLOB_CHARS)
        r = simple_command_refusal(seg, toks, ctx.vars, env, ctx.cwd)
        if r:
            return r
        stripped = _strip_wrappers(toks)
        if stripped and os.path.basename(stripped[0]).lower() in ("cd", "pushd") and ctx.unquoted_glob:
            if any(ch in a for a in stripped[1:] for ch in GLOB_CHARS):
                return "cd into a glob pattern moves the run to a directory the guard cannot see"
        try:
            redirects = redirect_targets(seg)
        except Refused as e:
            return str(e)
        for t in redirects:
            r = ctx.target(t)
            if r:
                return r
        r = check_segment(seg, ctx)
        if r:
            return r
        stripped = _strip_wrappers(toks)
        if stripped and os.path.basename(stripped[0]).lower() in ("cd", "pushd", "popd"):
            ctx.cwd = _cd(stripped, ctx)
        elif toks and all(ASSIGN_RX.match(t) for t in toks):
            # A bare assignment line: remember what it set for later expansion.
            for t in toks:
                name, value = ASSIGN_RX.match(t).groups()
                ctx.vars[name] = expand(value, ctx.vars, env)
    return None


TOOLKIT_PREFIX = "mcp__multi-agent-toolkit__"


# Keys a toolkit tool uses for a filesystem destination it writes. Checked with
# check_path so a toolkit call cannot write a screenshot, recording, build
# output or export over a protected path. MCP tools run outside the OS sandbox,
# so this is their only path boundary. The toolkit's write parameters are
# listed in test/fixtures/toolkit-path-params.json, generated from its tool
# schemas, and the unattended-guard test checks each one against this list.
TOOLKIT_PATH_KEYS = ("path", "filename", "out_dir", "output", "output_path", "output_dir",
                     "output_graph", "export_path", "derived_data_path", "result_bundle_path",
                     "figma_path", "screenshot_path", "video_path", "file_path")


def _toolkit_path_refusal(name, ti, policy, env, cwd):
    for key, value in ti.items():
        if not isinstance(value, str) or not value:
            continue
        if key in TOOLKIT_PATH_KEYS or key.endswith("_path"):
            r = check_path(value, cwd, policy, env)
            if r:
                return f"{name} {key}: {r}"
    return None


def check_toolkit(name, ti, policy, env, depth=0, cwd=None):
    """A multi-agent-toolkit tool call, by its short name (web_goto,
    ios_open_url, agent_run_steps). agent_run_steps dispatches other toolkit
    tools, so each step is judged as if it were called directly. Every
    path-typed argument is checked, including inside a step."""
    if not isinstance(ti, dict):
        return f"{name} arguments are not an object the guard can read"
    if name.startswith("research_"):
        return f"{name} sends the query to an outside service"
    if name == "web_eval":
        return "web_eval runs arbitrary JavaScript that can reach any host; refused unattended"
    if name == "agent_run_steps":
        steps = ti.get("steps")
        if depth or not isinstance(steps, list):
            return "agent_run_steps without a readable list of steps cannot be judged"
        for step in steps:
            if not isinstance(step, dict) or not isinstance(step.get("tool"), str):
                return "an agent_run_steps step without a tool name cannot be judged"
            sub = step["tool"]
            sub = sub[len(TOOLKIT_PREFIX):] if sub.startswith(TOOLKIT_PREFIX) else sub
            if sub.startswith("agent_"):
                return "agent_run_steps may not nest another agent_* tool"
            r = check_toolkit(sub, step.get("args") if step.get("args") is not None else {}, policy, env, depth + 1, cwd)
            if r:
                return f"agent_run_steps step {sub}: {r}"
        return None
    r = _toolkit_path_refusal(name, ti, policy, env, cwd)
    if r:
        return r
    if "url" in ti and not isinstance(ti["url"], str):
        # A non-string url (a list, an object) is not something the guard can
        # host-check; a web tool handed one is refused rather than waved through.
        return f"{name} was given a url that is not a string; the host cannot be checked"
    url = ti.get("url")
    if not isinstance(url, str) or not url:
        return None
    if name.startswith("web_"):
        return check_url_host(url, policy, env, name)
    return _web_url_refusal(url, policy, env, name)


def check(payload, env=None):
    """Reason string when the tool call is blocked, None when it is allowed."""
    policy = load_policy()
    tool = payload.get("tool_name") or ""
    ti = payload.get("tool_input") or {}
    cwd = payload.get("cwd") or os.getcwd()
    if tool in ("Edit", "Write", "NotebookEdit", "MultiEdit"):
        return check_path(ti.get("file_path") or ti.get("notebook_path") or "", cwd, policy, env)
    if tool.startswith(TOOLKIT_PREFIX):
        return check_toolkit(tool[len(TOOLKIT_PREFIX):], ti, policy, env, cwd=cwd)
    if tool == "WebFetch":
        url = ti.get("url")
        if isinstance(url, str) and url:
            return check_url_host(url, policy, env, "WebFetch")
        return None
    cmd = ti.get("command")
    if isinstance(cmd, str) and cmd:
        return check_bash(cmd, cwd, policy, env)
    return None


# --- git subcommand tokenizer, reused by pre-commit-check.sh ------------------

GIT_COMMIT_LIKE = {"commit", "commit-tree", "merge", "cherry-pick", "revert", "am"}


def git_subcommands(cmd, depth=0):
    """Every git subcommand across the simple commands of a shell line,
    including those inside `$(...)`, using the same wrapper-stripping and
    casefolding the guard uses. Refuse-structure is NOT applied here: the
    caller (pre-commit) only needs to know whether a commit happens."""
    subs = []
    inners = []
    if depth == 0:
        try:
            cmd = strip_heredocs(cmd)
        except Refused:
            pass
    if depth < 3:
        try:
            cmd, inners = extract_substitutions(cmd)
        except Refused:
            inners = []
    for seg in split_ops(cmd):
        toks = _strip_wrappers(tokenize(seg))
        if not toks:
            continue
        if os.path.basename(toks[0]).lower() == "git":
            sub, rest = _git_sub(toks)
            if sub:
                subs.append(sub)
            # A git alias could expand to a commit; treat an alias as commit-like.
            if git_config_refusal(toks):
                subs.append("commit")
    for inner in inners:
        subs.extend(git_subcommands(inner, depth + 1))
    return subs


COMMIT_WRITERS = ("commit", "commit-tree", "merge", "cherry-pick", "revert", "am")


def _git_dir_opt(toks):
    """The directory named by git's global `-C`, or None."""
    i = 1
    while i < len(toks):
        t = toks[i]
        if t == "-C" and i + 1 < len(toks):
            return toks[i + 1]
        if t in ("-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
            i += 2
        elif t.startswith("-"):
            i += 1
        else:
            return None
    return None


def _literal_dir(arg, cwd):
    """`arg` as an absolute directory, expanding a leading `~` / `$HOME` /
    `${HOME}` from the environment; "" when anything else would need the shell
    (a variable, a substitution, a glob, `~user`, `-`) or the base is unknown."""
    home = os.environ.get("HOME", "")
    for prefix in ("${HOME}", "$HOME", "~"):
        if home and (arg == prefix or arg.startswith(prefix + "/")):
            arg = home + arg[len(prefix):]
            break
    if not arg or arg == "-" or any(ch in arg for ch in "$`~*?"):
        return ""
    if os.path.isabs(arg):
        return os.path.normpath(arg)
    if cwd == "":
        return ""
    return os.path.normpath(os.path.join(cwd, arg))


def git_commit_dirs(cmd, base):
    """The directory each commit-writing git command on a shell line runs in,
    following `cd`/`pushd` and git's `-C` from `base`. "" marks a directory the
    line names in a way that cannot be read literally (`cd "$X"`, `cd -`)."""
    try:
        cmd = strip_heredocs(cmd)
    except Refused:
        pass
    cwd = base
    dirs = []
    for seg in split_ops(cmd):
        toks = _strip_wrappers(tokenize(seg))
        if not toks:
            continue
        head = os.path.basename(toks[0]).lower()
        if head in ("cd", "pushd"):
            args = [t for t in toks[1:] if t == "-" or not t.startswith("-")]
            cwd = _literal_dir(args[0], cwd) if args else ""
            continue
        if head != "git":
            continue
        sub, _ = _git_sub(toks)
        if sub not in COMMIT_WRITERS:
            continue
        opt = _git_dir_opt(toks)
        dirs.append(cwd if opt is None else _literal_dir(opt, cwd))
    return dirs


# `git add` and `git commit` options whose value is the next token.
ADD_VALUE_OPTIONS = ("--chmod",)
COMMIT_VALUE_OPTIONS = (
    "--message", "--file", "--reuse-message", "--reedit-message", "--author",
    "--date", "--template", "--fixup", "--squash", "--cleanup", "--trailer",
)
COMMIT_SHORT_VALUE = "mFCct"


def _add_scope(args):
    out = []
    specs_only = False
    i = 0
    while i < len(args):
        t = args[i]
        i += 1
        if specs_only or not t.startswith("-"):
            out.append("ADD " + t)
        elif t == "--":
            specs_only = True
        elif t in ("-A", "--all", "--no-ignore-removal") or t.startswith("--pathspec-from-file"):
            out.append("ADD_ALL")
        elif t in ("-u", "--update"):
            out.append("ADD_UPDATE")
        elif t in ADD_VALUE_OPTIONS:
            i += 1
        elif not t.startswith("--") and ("A" in t[1:] or "u" in t[1:]):
            out.append("ADD_ALL" if "A" in t[1:] else "ADD_UPDATE")
    return out


def _commit_scope(args):
    out = []
    specs_only = False
    i = 0
    while i < len(args):
        t = args[i]
        i += 1
        if specs_only or not t.startswith("-") or t == "-":
            out.append("COMMIT " + t)
        elif t == "--":
            specs_only = True
        elif t == "--all" or t.startswith("--pathspec-from-file"):
            out.append("COMMIT_ALL")
        elif t.startswith("--"):
            if t in COMMIT_VALUE_OPTIONS:
                i += 1
        else:
            for k, ch in enumerate(t[1:], start=1):
                if ch == "a":
                    out.append("COMMIT_ALL")
                if ch in COMMIT_SHORT_VALUE:
                    if k == len(t) - 1:
                        i += 1
                    break
    return out


def git_commit_scope(cmd, depth=0):
    """What a shell line adds to the commit it makes, beyond the index, as
    lines: `ADD_ALL` (`git add -A` or `.`-like), `ADD_UPDATE` (`-u`),
    `ADD <pathspec>`, `COMMIT_ALL` (`commit -a`) and `COMMIT <pathspec>`. The
    pre-commit hook scans the index plus these, so a dirty file the commit will
    not include is never judged, and `commit -a` is never missed."""
    out = []
    inners = []
    if depth == 0:
        try:
            cmd = strip_heredocs(cmd)
        except Refused:
            pass
    if depth < 3:
        try:
            cmd, inners = extract_substitutions(cmd)
        except Refused:
            inners = []
    for seg in split_ops(cmd):
        toks = _strip_wrappers(tokenize(seg))
        if not toks or os.path.basename(toks[0]).lower() != "git":
            continue
        sub, rest = _git_sub(toks)
        if sub == "add":
            out.extend(_add_scope(rest))
        elif sub == "commit":
            out.extend(_commit_scope(rest))
    for inner in inners:
        out.extend(git_commit_scope(inner, depth + 1))
    return out
