#!/usr/bin/python3 -I
"""LuLu alert explainer. When a LuLu "allow or block?" alert is on screen, find the held connection, work out which
program (and which app or bot behind it) is making it, and post a plain-English notification with a suggestion.
Rule-based only: no AI reads the connection, so a malicious program cannot talk its way into an "allow".
History: ~/.openmausbot-security/lulu-explainer/history.log
"""
import os, re, subprocess, time, json, socket, threading

HOME = os.path.expanduser("~")
DIR = os.path.join(HOME, ".openmausbot-security", "lulu-explainer")
HIST = os.path.join(DIR, "history.log")
POLL, HOLD = 1.5, 2.0

KNOWN = {  # host suffix -> what it is
    "github.com": "GitHub", "githubusercontent.com": "GitHub file downloads", "anthropic.com": "Anthropic (Claude)",
    "claude.ai": "Anthropic (Claude)", "openai.com": "OpenAI (Codex/ChatGPT)", "chatgpt.com": "OpenAI (ChatGPT)",
    "googleapis.com": "Google APIs (Gmail, Drive, Calendar)", "google.com": "Google", "gstatic.com": "Google",
    "npmjs.org": "the npm package registry", "npmjs.com": "npm", "pypi.org": "the Python package index",
    "pythonhosted.org": "Python package downloads", "vercel.com": "Vercel (website hosting)", "vercel.app": "Vercel",
    "cloudflare.com": "Cloudflare", "1password.com": "1Password", "slack.com": "Slack", "twilio.com": "Twilio",
    "apple.com": "Apple", "icloud.com": "Apple iCloud", "brew.sh": "Homebrew", "amazonaws.com": "Amazon Web Services",
    "sentry.io": "Sentry (error reports)", "objective-see.org": "Objective-See (LuLu updates)",
}
KNOWN_IPS = {r"^140\.82\.11[2-9]\.|^140\.82\.12[0-7]\.|^192\.30\.25[2-5]\.|^20\.(2[0-9]|20[0-9])\.": "GitHub"}

APPS = [  # (pattern in an ancestor's path/args, name)
    (r"OpenMausBot", "OpenMausBot (one of your bots)"), (r"/\.openmausbot/", "OpenMausBot (one of your bots)"),
    (r"Orca", "Orca"), (r"/codex\b|codex-", "Codex"), (r"/claude\b|claude-code|@anthropic-ai", "Claude Code"),
    (r"Ghostty|iTerm|Terminal\.app|WezTerm|kitty|Alacritty|Warp", "a terminal window you opened"),
    (r"Visual Studio Code|Cursor\.app|Zed", "your code editor"), (r"Granola", "Granola"), (r"Superset", "Superset"),
    (r"/opt/homebrew|/usr/local/Homebrew", "Homebrew"), (r"launchd$", "a background service"),
]
RISKY = re.compile(r"^(nc|ncat|netcat|socat|telnet|ftp|tftp)$")

def sh(args, t=3):
    try: return subprocess.run(args, capture_output=True, text=True, timeout=t).stdout
    except Exception: return ""

def syn_sent():
    out = {}
    for line in sh(["/usr/sbin/netstat", "-anv", "-p", "tcp"]).splitlines():
        if " SYN_SENT " not in line: continue
        parts = line.split()
        try:
            remote = parts[4]
            proc = " ".join(parts[10:-8]); pname, _, pid = proc.rpartition(":"); pid = int(pid)
            gen = parts[-6]
        except Exception: continue
        ip, port = remote.rsplit(".", 1)
        if ip.startswith(("127.", "fe80", "::1")): continue
        out[gen] = (pname, pid, ip, port)
    return out

def lulu_window():
    js = ('ObjC.import("CoreGraphics");var a=ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo(1,0)));'
          'JSON.stringify(a.filter(w=>/^LuLu$/.test(w.kCGWindowOwnerName)&&w.kCGWindowBounds.Height>120).length)')
    return sh(["/usr/bin/osascript", "-l", "JavaScript", "-e", js]).strip() not in ("", "0")

def rdns(ip):
    r = [None]
    def go():
        try: r[0] = socket.gethostbyaddr(ip)[0]
        except Exception: pass
    t = threading.Thread(target=go, daemon=True); t.start(); t.join(1.5); return r[0]

def resolve(name):
    r = [[]]
    def go():
        try: r[0] = [a[4][0] for a in socket.getaddrinfo(name, None)]
        except Exception: pass
    t = threading.Thread(target=go, daemon=True); t.start(); t.join(1.5); return r[0]

def lineage(pid):
    chain, seen = [], set()
    while pid and pid > 1 and pid not in seen and len(chain) < 15:
        seen.add(pid)
        o = sh(["/bin/ps", "-o", "ppid=", "-o", "args=", "-p", str(pid)]).strip()
        if not o: break
        ppid, _, args = o.partition(" "); chain.append((pid, args.strip()))
        try: pid = int(ppid)
        except ValueError: break
    return chain

def cwd(pid):
    o = sh(["/usr/sbin/lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"])
    m = re.search(r"^n(.+)$", o, re.M); return m.group(1) if m else ""

def bot_from(paths):
    for p in paths:
        m = re.search(r"\.openmausbot/(?:task-)?workspaces/([0-9a-f-]{36})", p)
        if m:
            try:
                b = json.load(open(os.path.join(HOME, ".openmausbot/bots.json")))
                for x in (b if isinstance(b, list) else b.get("bots", [])):
                    if isinstance(x, dict) and x.get("id") == m.group(1): return x.get("name")
            except Exception: pass
            return "a bot"
    return None

def what_host(host, ip):
    for suf, name in KNOWN.items():
        if host and (host == suf or host.endswith("." + suf)): return name
    for pat, name in KNOWN_IPS.items():
        if re.match(pat, ip): return name
    return None

def explain(pname, pid, ip, port):
    chain = lineage(pid)
    args = chain[0][1] if chain else pname
    exe = os.path.basename(args.split()[0]) if args else pname
    # Only trust a hostname from the connecting program's own arguments, and only if it really resolves to this IP.
    host = None
    for cand in re.findall(r"(?:@|https?://|\s)([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})(?=[:/\s]|$)", " " + args):
        if ip in resolve(cand): host = cand; break
    host = host or rdns(ip) or ip
    here = cwd(pid) if pid else ""
    owner = next((name for pat, name in APPS for _, a in chain[1:] if re.search(pat, a)), None)
    bot = bot_from([here] + [a for _, a in chain] + [cwd(p) for p, _ in chain[1:6]]) if owner and "OpenMausBot" in owner else None
    if bot: owner = f"your bot \"{bot}\"" if bot != "a bot" else "one of your bots"
    svc = what_host(host, ip)
    lan = re.match(r"^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)", ip)
    proj = os.path.basename(here) if here.startswith(os.path.join(HOME, "dev")) else ""
    gitish = re.search(r"git-(upload|receive)-pack|git-lfs", args) or any(re.search(r"(^|/)git(-remote-\w+)?(\s|$)", a) for _, a in chain[1:3])
    # what it is
    if exe == "ssh" and gitish and svc == "GitHub":
        what = f"git is syncing{(' ' + proj) if proj else ' a repo'} with GitHub over SSH (pull, fetch or push)."
    elif exe == "ssh":
        what = f"SSH is connecting to {host} (a remote login or file copy)."
    elif svc:
        what = f"{exe} is talking to {svc} ({host})."
    elif lan:
        what = f"{exe} is reaching a device on your home network ({ip}, port {port})."
    else:
        what = f"{exe} is connecting to {host}, port {port}, which isn't on the known list."
    who = f"Started by {owner}." if owner else "Couldn't tell which app started it."
    # suggestion
    if RISKY.match(exe):
        verdict, rec = "BLOCK", "Raw network tools like this are a common way to smuggle data out. Block unless you just ran it yourself."
    elif svc or (lan and owner):
        verdict, rec = "ALLOW", f"Expected. Allow, and pick 'Endpoint' ({host}) rather than 'Any' so it only covers this destination."
    elif owner and "bot" in owner:
        verdict, rec = "CHECK", "A bot is contacting an unfamiliar site. If you didn't ask it to, Block (you can allow later in LuLu)."
    else:
        verdict, rec = "CHECK", "Unfamiliar. If you didn't just start something that needs this, Block. You can undo it later in LuLu's rules."
    lines = [what, who, rec]
    detail = "\n".join([time.strftime("%Y-%m-%d %H:%M:%S") + f"  [{verdict}]  {exe} -> {host} ({ip}:{port})", *("  " + l for l in lines),
                        "  folder: " + (here or "?"), "  chain: " + " <- ".join(os.path.basename(a.split()[0]) for _, a in chain if a)]) + "\n"
    with open(HIST, "a") as f: f.write(detail + "\n")
    title = {"ALLOW": "LuLu: looks fine", "BLOCK": "LuLu: block this", "CHECK": "LuLu: your call"}[verdict]
    body = f"{what} {who} {rec}"
    esc = lambda s: s.replace("\\", "\\\\").replace('"', '\\"')
    sh(["/usr/bin/osascript", "-e", f'display notification "{esc(body)}" with title "{esc(title)}" subtitle "{esc(exe + " -> " + host)}" sound name "Ping"'])

def main():
    os.makedirs(DIR, exist_ok=True)
    first, done, recent = {}, set(), {}
    while True:
        try:
            now = time.time(); cur = syn_sent()
            for g in cur: first.setdefault(g, now)
            for g in list(first):
                if g not in cur: first.pop(g, None); done.discard(g)
            stale = [g for g in cur if HOLD <= now - first[g] <= 45 and g not in done]
            if stale and lulu_window():
                for g in sorted(stale, key=lambda g: first[g])[:3]:
                    done.add(g); key = (cur[g][0], cur[g][2], cur[g][3])
                    if now - recent.get(key, 0) < 600: continue  # same program+destination explained in the last 10 min
                    recent[key] = now; explain(*cur[g])
        except Exception as e:
            with open(HIST, "a") as f: f.write(f"{time.strftime('%F %T')} explainer error: {e}\n")
        time.sleep(POLL)

if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1 and sys.argv[1] == "--test":  # --test <pid> <ip> <port>
        explain("test", int(sys.argv[2]), sys.argv[3], sys.argv[4])
    else: main()
