From 45edc5432c4c36fc6121e99ad640245a779b4cc1 Mon Sep 17 00:00:00 2001 From: operator Date: Mon, 5 Oct 2026 17:43:04 +0000 Subject: [PATCH] feat(approvals): integrate approval-hold detection and auto-remediation into fleet alert, loop diagnostics, and muse API --- bin/gravity.py | 38 ++++++++++++++ bin/muse-chat-api.py | 118 +++++++++++++++++++++++++++++------------- bin/netvm-registry.py | 12 ++++- 3 files changed, 132 insertions(+), 36 deletions(-) diff --git a/bin/gravity.py b/bin/gravity.py index 2a5d764..7585dc3 100644 --- a/bin/gravity.py +++ b/bin/gravity.py @@ -624,6 +624,26 @@ def diagnose_breaks() -> list: "remedy": f"Check agent {l['agent']} browser tab with 'super fleet status' or nudge via 'super dm send'." }) + # 4. Check for agents held up on approvals + try: + import approvals + fleet_apps = approvals.check_fleet_approvals() + for app in fleet_apps: + if app.get("has_pending"): + node = app["node"] + is_trusted = app.get("is_trusted", False) + ip = app.get("ip") or "unknown target" + breaks.append({ + "type": "approval_blocked", + "severity": "WARNING" if is_trusted else "CRITICAL", + "component": f"node:{node}", + "agent": node, + "detail": f"Agent {node} is held up on browser approval for {ip}", + "remedy": f"Run 'box approvals auto' or 'box approvals allow {node}'." + }) + except Exception: + pass + return breaks @@ -730,6 +750,24 @@ def remediate_breaks(dry_run=False) -> dict: except Exception: pass + # Auto-remediate trusted approval blocks + try: + import approvals + fleet_apps = approvals.check_fleet_approvals() + for app in fleet_apps: + if app.get("has_pending") and app.get("is_trusted"): + node = app["node"] + if not dry_run: + approvals.allow_node_approval(node, caller="loop-remediate") + remediated.append({ + "type": "approval_auto_allowed", + "agent": node, + "target": app.get("ip"), + "action": f"Auto-approved trusted browser request on {node} ({app.get('ip')})" + }) + except Exception: + pass + # 3. Alert on hard breakages if any exist if escalated and not dry_run: job_log = Path("/home/super/Projects/NetVM/job-log.jsonl") diff --git a/bin/muse-chat-api.py b/bin/muse-chat-api.py index 3f75316..07aa2dc 100755 --- a/bin/muse-chat-api.py +++ b/bin/muse-chat-api.py @@ -20,7 +20,7 @@ Usage: Exit codes: 0 ok, 1 error, 2 APPROVAL_NEEDED (human decision), 3 DOM_NOT_READY (browser not in expected chat state - safe to retry). """ -import json, urllib.request, websocket, time, sys, argparse, importlib.util +import json, urllib.request, websocket, time, sys, argparse, importlib.util, re import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: @@ -44,8 +44,8 @@ def _load_accounts(): spec.loader.exec_module(mod) accounts = {} for node, rec in mod.load().items(): - accounts[node] = (node, "http://127.0.0.1:%d/json/list" % - rec["cdp_port"]) + peer_ip = rec.get("peer_ip", "127.0.0.1") + accounts[node] = (node, "http://%s:%d/json/list" % (peer_ip, rec["cdp_port"])) return accounts @@ -59,8 +59,33 @@ TRUSTED_IPS = { } def get_page(node, cdp_url): - with urllib.request.urlopen(cdp_url, timeout=5) as r: - ts = json.load(r) + urls = [cdp_url] + m = re.search(r":(\d+)/", cdp_url) + if m: + port = m.group(1) + if "127.0.0.1" in cdp_url: + path = "/home/super/Projects/NetVM/bin/netvm-registry.py" + spec = importlib.util.spec_from_file_location("netvm_registry", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + pip = mod.peer_ip_for(node) + if pip: + urls.append(f"http://{pip}:{port}/json/list") + else: + urls.append(f"http://127.0.0.1:{port}/json/list") + ts = None + last_err = None + for u in urls: + try: + with urllib.request.urlopen(u, timeout=5) as r: + ts = json.load(r) + break + except Exception as e: + last_err = e + continue + if not ts: + print(f"ERROR: No page found ({last_err})", file=sys.stderr) + sys.exit(1) pages = [t for t in ts if t.get('type') == 'page'] if not pages: print("ERROR: No page found", file=sys.stderr) @@ -93,28 +118,45 @@ def check_approvals(ws): """ result = ev(ws, """(() => { const dialogs = []; - // Look for permission prompts (common patterns) - const body = document.body.innerText; - // Check for "Allow ... to share" pattern - if (body.includes('Allow') && body.includes('to share')) { - // Find the dialog - const els = [...document.querySelectorAll('*')].filter(el => { - const t = el.innerText || ''; - return t.includes('Allow') && t.includes('to share') && t.length < 500; - }); - for (const el of els.slice(0,3)) { - dialogs.push(el.innerText.slice(0,200)); + // 1. Check confirmed structural selectors + const headers = [...document.querySelectorAll('[data-testid="approval-panel-header"], [data-testid*="approval"]')]; + for (const h of headers) { + let card = h; + for (let i = 0; i < 6 && card && card.parentElement && card.parentElement !== document.body; i++) { + if (card.querySelector('button[data-hatch-approval-primary-action="true"]') || + [...card.querySelectorAll('button')].some(b => { + const t = (b.innerText||'').toLowerCase(); + return t.includes('allow once') || t.includes('deny'); + })) { + dialogs.push(card.innerText.slice(0, 500)); + break; + } + card = card.parentElement; } } - // Check for other permission patterns - const perm_btns = [...document.querySelectorAll('button')].filter(b => { - const t = (b.innerText||'').toLowerCase(); - return t.includes('allow') || t.includes('deny') || t.includes('block'); - }); - if (perm_btns.length >= 2 && dialogs.length === 0) { - // Might be a permission dialog - const parent = perm_btns[0].closest('div'); - if (parent) dialogs.push(parent.innerText.slice(0,200)); + // 2. Check for "Allow ... to share" pattern if not found + if (dialogs.length === 0) { + const body = document.body ? document.body.innerText : ''; + if (body.includes('Allow') && body.includes('to share')) { + const els = [...document.querySelectorAll('*')].filter(el => { + const t = el.innerText || ''; + return t.includes('Allow') && t.includes('to share') && t.length < 500; + }); + for (const el of els.slice(0,3)) { + dialogs.push(el.innerText.slice(0,200)); + } + } + } + // 3. Check for other permission patterns + if (dialogs.length === 0) { + const perm_btns = [...document.querySelectorAll('button')].filter(b => { + const t = (b.innerText||'').toLowerCase(); + return t.includes('allow') || t.includes('deny') || t.includes('block'); + }); + if (perm_btns.length >= 2) { + const parent = perm_btns[0].closest('div'); + if (parent) dialogs.push(parent.innerText.slice(0,200)); + } } return JSON.stringify(dialogs); })()""") @@ -128,18 +170,24 @@ def check_approvals(ws): # Extract IP if present import re ips = re.findall(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', d) - # Only block for IP-based permission dialogs. Dialogs without IPs - # are likely false positives (chat content, UI text) - don't block. - if not ips: - continue - is_trusted = any(ip in TRUSTED_IPS for ip in ips) + # Check trust: if IP present, must be in TRUSTED_IPS; if no IP, untrusted approval dialog + if ips: + is_trusted = any(ip in TRUSTED_IPS for ip in ips) + else: + # Check if this is a known false positive or real dialog + if "allow once" in d.lower() or "approval-panel" in d.lower() or "wants to" in d.lower(): + is_trusted = False + else: + continue + if is_trusted: - # Auto-approve: click "Allow once" or "Allow" + # Auto-approve: click primary action or "Allow once" / "Allow" clicked = ev(ws, """(async()=>{ - const b = [...document.querySelectorAll('button')].find(x=>{ - const t = (x.innerText||'').toLowerCase(); - return t.includes('allow once') || t === 'allow'; - }); + const b = document.querySelector('button[data-hatch-approval-primary-action="true"]') || + [...document.querySelectorAll('button')].find(x=>{ + const t = (x.innerText||'').toLowerCase().trim(); + return t === 'allow once' || t.includes('allow once') || t === 'allow'; + }); if (b) { b.click(); return 'clicked:'+b.innerText.slice(0,20); } return 'NOTFOUND'; })()""", True) diff --git a/bin/netvm-registry.py b/bin/netvm-registry.py index 86736ac..a9142a8 100755 --- a/bin/netvm-registry.py +++ b/bin/netvm-registry.py @@ -14,6 +14,7 @@ CLI: netvm-registry.py # prints "node:port" lines for active nodes netvm-registry.py # prints just the port (for bash) """ +import hashlib import os import re import sys @@ -45,9 +46,12 @@ def load(registry_path=None): port_n = int(port) except ValueError: continue + tag = hashlib.sha256(node.encode()).hexdigest()[:8] + idx = int(tag[:3], 16) % 200 + 10 + peer_ip = f"10.201.{idx}.2" nodes[node] = {"netns": netns, "egress_ip": egress, "cdp_port": port_n, "status": status, - "agent": agent} + "agent": agent, "peer_ip": peer_ip} except FileNotFoundError: pass return nodes @@ -59,6 +63,12 @@ def port_for(node, registry_path=None): return rec["cdp_port"] if rec else None +def peer_ip_for(node, registry_path=None): + """CDP peer IP for a node, or None if the node isn't registered.""" + rec = load(registry_path).get(node) + return rec["peer_ip"] if rec else None + + def active_nodes(registry_path=None): """{node: port} for nodes with status == active.""" return {n: r["cdp_port"] for n, r in load(registry_path).items()