#!python
"""sandbroker-bridge - relays file-queue requests to sandbroker's unix socket.

Runs OUTSIDE the sandbox, as the user (in the claude-broker group). The
sandboxed agent can't reach sandbroker's socket (srt seccomp blocks AF_UNIX), so
sandbroker drops request files in a PER-SESSION queue and this watcher relays
each to sandbroker and writes the reply back.

Queue layout (C11 - per-session namespace):

    /tmp/sandbroker-bridge/<sid>/req/<rid>.json    sandbroker writes, bridge reads
    /tmp/sandbroker-bridge/<sid>/resp/<rid>.json   bridge writes, sandbroker reads

SECURITY:
  * The queue carries only the operation in ({verb,ref,args,justification,
    code_hash}) and {ok,error} out. No secret value ever passes through it; the
    plaintext approval code lives only in the chat, never the queue.
  * The SID is taken from the QUEUE PATH, not the request body, and stamped onto
    the relayed request. A session can only write into its own <sid>/ directory
    (its SID is injected by the launcher), so it cannot claim another session's
    identity through the body (C2).
  * Only the `invoke` action is relayed. A `register` action (which binds a SID
    to a vault) is REFUSED here, so the sandboxed agent cannot self-register a
    vault -- registration is the launcher's job over the direct socket (C1/C2).
  * Requests are forwarded to sandbroker as a JSON object; there is no shell, no
    interpolation, so a request cannot become a command. sandbroker still validates
    everything and enforces ref_allow + the registered vault.

Talks to sandbroker DIRECTLY (not via sandbroker) so it can never recurse into its
own queue.
"""

import json
import os
import re
import socket
import sys
import time

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")
POLL = 0.15
SID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,128}$")
# Operation fields relayed verbatim. sid + action are set by the bridge, never
# taken from the body.
ALLOWED_KEYS = ("verb", "ref", "args", "justification", "code_hash", "vault")


def ensure_base():
    os.makedirs(BRIDGE_DIR, exist_ok=True)
    try:
        os.chmod(BRIDGE_DIR, 0o700)  # keep other local users out of the queue
    except OSError:
        pass


def relay_to_sandbroker(request):
    """Forward one request to sandbroker and return its typed reply."""
    conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    conn.settimeout(150)
    try:
        conn.connect(SOCKET_PATH)
        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
    except OSError as exc:
        return {"ok": False, "error": "failed", "detail": "socket: %s" % exc}
    finally:
        conn.close()
    line = buf.decode("utf-8", "replace").strip()
    try:
        return json.loads(line) if line else {"ok": False, "error": "failed"}
    except ValueError:
        return {"ok": False, "error": "failed"}


def write_response(resp_dir, rid, reply):
    os.makedirs(resp_dir, exist_ok=True)
    tmp = os.path.join(resp_dir, rid + ".tmp")
    final = os.path.join(resp_dir, rid + ".json")
    with open(tmp, "w") as fh:
        json.dump(reply, fh)
    os.rename(tmp, final)  # atomic: client only sees the complete file


def process_one(sid, req_dir, resp_dir, name):
    rid = name[:-5]  # strip .json
    path = os.path.join(req_dir, name)
    try:
        with open(path) as fh:
            request = json.load(fh)
    except (ValueError, OSError):
        request = None
    try:
        os.unlink(path)  # consume the request regardless
    except OSError:
        pass
    if not isinstance(request, dict):
        write_response(resp_dir, rid, {"ok": False, "error": "denied", "detail": "malformed"})
        return
    # Relay only invoke + catalog (both agent-facing). register/lock are refused
    # here so the sandbox can't self-register a vault or clear sessions.
    action = request.get("action", "invoke")
    if action not in ("invoke", "catalog"):
        write_response(resp_dir, rid, {"ok": False, "error": "denied", "detail": "action_not_allowed"})
        return
    # invoke must carry a verb; catalog carries none.
    if action == "invoke" and "verb" not in request:
        write_response(resp_dir, rid, {"ok": False, "error": "denied", "detail": "malformed"})
        return
    clean = {k: request[k] for k in ALLOWED_KEYS if k in request}
    clean["action"] = action
    clean["sid"] = sid   # authoritative: bound to the queue path, not the body
    write_response(resp_dir, rid, relay_to_sandbroker(clean))


def session_queues():
    """Yield (sid, req_dir, resp_dir) for each per-session queue dir present."""
    try:
        entries = os.listdir(BRIDGE_DIR)
    except OSError:
        ensure_base()
        return
    for sid in entries:
        if not SID_RE.match(sid):
            continue
        req_dir = os.path.join(BRIDGE_DIR, sid, "req")
        if os.path.isdir(req_dir):
            yield sid, req_dir, os.path.join(BRIDGE_DIR, sid, "resp")


def main():
    ensure_base()
    sys.stderr.write("sandbroker-bridge watching %s/<sid>/req\n" % BRIDGE_DIR)
    sys.stderr.flush()
    while True:
        did_work = False
        for sid, req_dir, resp_dir in session_queues():
            try:
                names = [n for n in os.listdir(req_dir) if n.endswith(".json")]
            except OSError:
                names = []
            for name in names:
                process_one(sid, req_dir, resp_dir, name)
                did_work = True
        if not did_work:
            time.sleep(POLL)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
