#!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 agent-facing actions (invoke, catalog, propose) are relayed. A
    `register` action (which binds a SID to a vault) and the proposal DECISION
    actions (approve/reject) are REFUSED here, so the sandboxed agent cannot
    self-register a vault or approve its own proposal -- registration is the
    launcher's job and approval is the human's, both over the direct socket
    (C1/C2). `propose` only spools an inert request; it grants nothing.
  * 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. `proposal` carries a capability REQUEST (the propose
# action) -- relaying it is safe because propose only spools an inert proposal;
# it grants and installs nothing, and the DECISION actions are never relayed.
ALLOWED_KEYS = ("verb", "ref", "args", "justification", "code_hash", "vault",
                "proposal")


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 the agent-facing actions. invoke + catalog USE existing
    # capabilities; propose REQUESTS a new one (inert until a human approves +
    # installs it). register/lock and the proposal DECISION actions
    # (proposal_challenge/approve/reject) are refused here so the sandbox can't
    # self-register a vault, clear sessions, or approve its own proposal.
    action = request.get("action", "invoke")
    if action not in ("invoke", "catalog", "propose"):
        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
