#!/usr/bin/env python3
"""sandbroker - thin client for sandbroker.

The ONLY interface Claude uses to cause a secret to be USED. Sends a typed
request and prints the typed response verbatim. Never carries a secret VALUE.

Auto-tier verbs (frictionless):

    sandbroker canary.probe    --ref file://canary
    sandbroker ref.check       --ref 'akv://ent-prd-cus-kv/GRIZBOT-DISCORD-TOKEN'

Confirm-tier verbs (approval window + Windows Hello). sandbroker generates a
one-time APPROVAL CODE, prints it in the chat, and sends only its hash. The
human types that code into the Windows approval window and then approves with
Hello; the daemon binds the session and replays the operation. The session id is
taken from $SANDBROKER_SID (the launcher injects it; unset falls back to a shared
dev id).

    sandbroker deploy.push --ref op://proj-prod/DB/pw --justification "run the 3pm migration"
       -> prints:  APPROVAL CODE: 4 8 2 1 5 9   (relay this to the human)
       -> {"ok": false, "error": "hello_pending"}
    # human types the code into the Windows window + approves Hello, then re-run
    # the SAME command to poll:
    sandbroker deploy.push --ref op://proj-prod/DB/pw --justification "run the 3pm migration"
       -> {"ok": false, "error": "hello_waiting"}   # still waiting (do NOT show a new code)
       -> {"ok": true}                              # approved; op replayed

Two transports, tried in order:
  1. Unix socket to sandbroker directly (normal, unsandboxed sessions).
  2. File-queue BRIDGE (when the socket is unreachable, e.g. inside the srt
     sandbox, whose seccomp filter blocks AF_UNIX). Drops a request file in
     /tmp/sandbroker-bridge/req and polls resp; the host-side sandbroker-bridge
     watcher relays to sandbroker. The queue carries only the request fields in and
     {ok,error} out -- NEVER a secret. This is what lets the sandboxed default
     agent use secrets without ever seeing them.

Set SANDBROKER_NO_BRIDGE=1 to force socket-only (the watcher sets this so it can't
recurse into its own queue).

Output: one JSON object {"ok": true} or {"ok": false, "error": "..."} on stdout.
The human-facing approval-code banner is printed to stderr, so stdout stays a
clean machine contract.
Exit: 0 ok, 1 typed failure, 2 client/transport problem.
"""

import hashlib
import json
import os
import secrets
import socket
import sys
import time
import uuid

SOCKET_PATH = os.environ.get("SANDBROKER_SOCKET") or os.path.join(os.environ.get("SANDBROKER_BASE", "/opt/sandbroker"), "run", "sandbroker.sock")
BRIDGE_DIR = os.environ.get("SANDBROKER_BRIDGE_DIR", "/tmp/sandbroker-bridge")
NO_BRIDGE = os.environ.get("SANDBROKER_NO_BRIDGE") == "1"
BRIDGE_TIMEOUT = 150.0

# Must match sandbroker.DEFAULT_SID so the code hash the human satisfies matches
# what the daemon/approver compute when no launcher-minted SID is present.
DEFAULT_SID = "_nosid"


def emit(obj):
    print(json.dumps(obj))
    return 0 if obj.get("ok") else 1


def parse(argv):
    sid = os.environ.get("SANDBROKER_SID")  # launcher-injected; None -> daemon default

    # `sandbroker list [env]` -> catalog of item references in a vault (names only,
    # never values). Env defaults to the daemon's default (Dev). E.g.:
    #   sandbroker list              -> Dev items
    #   sandbroker list Production   -> Production items
    if argv[1] == "list":
        request = {"action": "catalog"}
        if len(argv) > 2 and argv[2]:
            request["vault"] = argv[2]
        if sid:
            request["sid"] = sid
        return request

    request = {"action": "invoke", "verb": argv[1], "args": {}}
    if sid:
        request["sid"] = sid
    rest, i = argv[2:], 0
    while i < len(rest):
        tok = rest[i]
        if tok == "--ref":
            i += 1
            if i >= len(rest):
                raise ValueError("--ref requires a value")
            request["ref"] = rest[i]
        elif tok == "--justification":
            i += 1
            if i >= len(rest):
                raise ValueError("--justification requires a value")
            request["justification"] = rest[i]
        elif "=" in tok:
            k, _, v = tok.partition("=")
            request["args"][k] = v
        else:
            raise ValueError("unparsed argument %r" % tok)
        i += 1
    return request


def via_socket(request):
    """Direct unix socket. Raises OSError if unreachable (-> caller tries bridge)."""
    conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    conn.settimeout(150)
    conn.connect(SOCKET_PATH)  # OSError here on EPERM (sandbox) / ENOENT
    try:
        conn.sendall((json.dumps(request) + "\n").encode("utf-8"))
        buf = b""
        while b"\n" not in buf:
            chunk = conn.recv(4096)
            if not chunk:
                break
            buf += chunk
    finally:
        conn.close()
    line = buf.decode("utf-8", "replace").strip()
    return json.loads(line) if line else {"ok": False, "error": "failed"}


def via_bridge(request):
    """File-queue relay via this session's per-session queue namespace (C11).
    Atomic write (tmp+rename) so the watcher never reads a partial request; poll
    for the atomically-written response. The bridge takes the SID from the queue
    PATH, so the session's identity is bound to its own directory."""
    sid = os.environ.get("SANDBROKER_SID") or "_nosid"
    session_dir = os.path.join(BRIDGE_DIR, sid)
    req_dir = os.path.join(session_dir, "req")
    resp_dir = os.path.join(session_dir, "resp")
    if not os.path.isdir(BRIDGE_DIR):
        return {"ok": False, "error": "denied",
                "detail": "sandbroker-bridge not running (no %s)" % BRIDGE_DIR}
    os.makedirs(req_dir, exist_ok=True)
    os.makedirs(resp_dir, exist_ok=True)
    rid = uuid.uuid4().hex
    tmp = os.path.join(req_dir, rid + ".tmp")
    final = os.path.join(req_dir, rid + ".json")
    resp = os.path.join(resp_dir, rid + ".json")
    with open(tmp, "w") as fh:
        json.dump(request, fh)
    os.rename(tmp, final)  # atomic: watcher only globs *.json

    deadline = time.time() + BRIDGE_TIMEOUT
    while time.time() < deadline:
        if os.path.exists(resp):
            try:
                with open(resp) as fh:
                    out = json.load(fh)
            except (ValueError, OSError):
                time.sleep(0.05)
                continue
            try:
                os.unlink(resp)
            except OSError:
                pass
            return out
        time.sleep(0.1)
    try:
        os.unlink(final)
    except OSError:
        pass
    return {"ok": False, "error": "failed", "detail": "bridge timeout"}


def send(request):
    try:
        return via_socket(request)
    except OSError:
        if NO_BRIDGE:
            return {"ok": False, "error": "denied",
                    "detail": "cannot reach sandbroker socket"}
        return via_bridge(request)


def _spaced(code):
    return " ".join(code)


def print_code_banner(code, out=sys.stderr):
    """Human-facing approval banner (stderr, so stdout stays pure JSON)."""
    line = "=" * 60
    out.write(
        "\n%s\n"
        "  APPROVAL REQUIRED  (confirm tier)\n"
        "  APPROVAL CODE:   %s\n\n"
        "  Type this code into the Windows approval window that just\n"
        "  appeared, then approve with Windows Hello. Re-run the same\n"
        "  command to check status.\n"
        "%s\n\n" % (line, _spaced(code), line)
    )
    out.flush()


def print_waiting_banner(out=sys.stderr):
    out.write(
        "\n[sandbroker] Approval still pending. Use the code already shown\n"
        "           (do not request a new one). Approve in the Windows\n"
        "           window + Hello, then re-run to poll.\n\n"
    )
    out.flush()


def print_reviewing_banner(out=sys.stderr):
    out.write(
        "\n[sandbroker] Request is under automated review by the independent AI\n"
        "           auditor. Waiting for its decision -- it will either auto-\n"
        "           approve or escalate to a human. No action needed yet.\n\n"
    )
    out.flush()


def print_catalog(resp):
    """Render `sandbroker list` output: the item catalog for the bound vault
    (names/ids only -- never values)."""
    items = resp.get("items", [])
    if not resp.get("ok"):
        return emit(resp)
    if not items:
        sys.stderr.write("(no items, or session has no vault)\n")
    else:
        sys.stderr.write("%d item(s) in this session's vault "
                         "(append /<field>, e.g. /password, to reference):\n" % len(items))
        for it in items:
            sys.stderr.write("  %-40s [%s]\n" % (it.get("title", "") or "(untitled)", it.get("id", "")))
    print(json.dumps(resp))
    return 0


def main(argv):
    if len(argv) < 2 or argv[1] in ("-h", "--help"):
        print(__doc__)
        return 0

    # `sandbroker setup` -> the guided installer wizard (sandbroker/setup.py). Handled
    # before the invoke/approval-code machinery: setup never touches the daemon
    # socket, it lays out the install and starts the services. Bootstrap the
    # sibling package onto sys.path so this works from a checkout or a pipx install.
    if argv[1] == "setup":
        import os as _os
        _repo = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
        if _repo not in sys.path:
            sys.path.insert(0, _repo)
        from sandbroker.setup import main as setup_main
        return setup_main(argv[2:])

    try:
        request = parse(argv)
    except ValueError as exc:
        return emit({"ok": False, "error": "denied", "detail": str(exc)}) or 2

    # Catalog (list) needs no approval code; render its items.
    if request.get("action") == "catalog":
        return print_catalog(send(request))

    # Generate a per-invocation approval code and attach ONLY its hash. The
    # plaintext code stays here and is shown to the human only if the daemon
    # opens a fresh approval cycle (hello_pending). The daemon never receives
    # the plaintext; it lives only in this session's chat and the human's typing.
    sid = request.get("sid", DEFAULT_SID)
    code = "%06d" % secrets.randbelow(10 ** 6)
    request["code_hash"] = hashlib.sha256(("%s:%s" % (sid, code)).encode()).hexdigest()

    resp = send(request)
    err = resp.get("error")

    # AI auditor fast-path: the daemon is reviewing this request. Poll a few times
    # (short, automated) before resolving to the human/ok outcome. The SAME code is
    # reused across polls, so if the auditor escalates, the code shown matches the
    # daemon's stored hash. The daemon fail-closes to a human by its own deadline,
    # so this loop always resolves.
    reviews = 0
    while err == "auditor_reviewing" and reviews < 6:
        if reviews == 0:
            print_reviewing_banner()
        try:
            delay = max(1, min(int(resp.get("retry_after", 5)), 30))
        except (TypeError, ValueError):
            delay = 5
        time.sleep(delay)
        reviews += 1
        resp = send(request)
        err = resp.get("error")

    if err == "hello_pending":
        print_code_banner(code)
    elif err == "hello_waiting":
        print_waiting_banner()
    return emit(resp)


if __name__ == "__main__":
    sys.exit(main(sys.argv))
