feat(approvals): integrate approval-hold detection and auto-remediation into fleet alert, loop diagnostics, and muse API

This commit is contained in:
operator
2026-10-05 17:43:04 +00:00
parent 611181b81e
commit 45edc5432c
3 changed files with 132 additions and 36 deletions
+38
View File
@@ -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'." "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 return breaks
@@ -730,6 +750,24 @@ def remediate_breaks(dry_run=False) -> dict:
except Exception: except Exception:
pass 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 # 3. Alert on hard breakages if any exist
if escalated and not dry_run: if escalated and not dry_run:
job_log = Path("/home/super/Projects/NetVM/job-log.jsonl") job_log = Path("/home/super/Projects/NetVM/job-log.jsonl")
+83 -35
View File
@@ -20,7 +20,7 @@ Usage:
Exit codes: 0 ok, 1 error, 2 APPROVAL_NEEDED (human decision), Exit codes: 0 ok, 1 error, 2 APPROVAL_NEEDED (human decision),
3 DOM_NOT_READY (browser not in expected chat state - safe to retry). 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 import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try: try:
@@ -44,8 +44,8 @@ def _load_accounts():
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
accounts = {} accounts = {}
for node, rec in mod.load().items(): for node, rec in mod.load().items():
accounts[node] = (node, "http://127.0.0.1:%d/json/list" % peer_ip = rec.get("peer_ip", "127.0.0.1")
rec["cdp_port"]) accounts[node] = (node, "http://%s:%d/json/list" % (peer_ip, rec["cdp_port"]))
return accounts return accounts
@@ -59,8 +59,33 @@ TRUSTED_IPS = {
} }
def get_page(node, cdp_url): def get_page(node, cdp_url):
with urllib.request.urlopen(cdp_url, timeout=5) as r: urls = [cdp_url]
ts = json.load(r) 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'] pages = [t for t in ts if t.get('type') == 'page']
if not pages: if not pages:
print("ERROR: No page found", file=sys.stderr) print("ERROR: No page found", file=sys.stderr)
@@ -93,28 +118,45 @@ def check_approvals(ws):
""" """
result = ev(ws, """(() => { result = ev(ws, """(() => {
const dialogs = []; const dialogs = [];
// Look for permission prompts (common patterns) // 1. Check confirmed structural selectors
const body = document.body.innerText; const headers = [...document.querySelectorAll('[data-testid="approval-panel-header"], [data-testid*="approval"]')];
// Check for "Allow ... to share" pattern for (const h of headers) {
if (body.includes('Allow') && body.includes('to share')) { let card = h;
// Find the dialog for (let i = 0; i < 6 && card && card.parentElement && card.parentElement !== document.body; i++) {
const els = [...document.querySelectorAll('*')].filter(el => { if (card.querySelector('button[data-hatch-approval-primary-action="true"]') ||
const t = el.innerText || ''; [...card.querySelectorAll('button')].some(b => {
return t.includes('Allow') && t.includes('to share') && t.length < 500; const t = (b.innerText||'').toLowerCase();
}); return t.includes('allow once') || t.includes('deny');
for (const el of els.slice(0,3)) { })) {
dialogs.push(el.innerText.slice(0,200)); dialogs.push(card.innerText.slice(0, 500));
break;
}
card = card.parentElement;
} }
} }
// Check for other permission patterns // 2. Check for "Allow ... to share" pattern if not found
const perm_btns = [...document.querySelectorAll('button')].filter(b => { if (dialogs.length === 0) {
const t = (b.innerText||'').toLowerCase(); const body = document.body ? document.body.innerText : '';
return t.includes('allow') || t.includes('deny') || t.includes('block'); if (body.includes('Allow') && body.includes('to share')) {
}); const els = [...document.querySelectorAll('*')].filter(el => {
if (perm_btns.length >= 2 && dialogs.length === 0) { const t = el.innerText || '';
// Might be a permission dialog return t.includes('Allow') && t.includes('to share') && t.length < 500;
const parent = perm_btns[0].closest('div'); });
if (parent) dialogs.push(parent.innerText.slice(0,200)); 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); return JSON.stringify(dialogs);
})()""") })()""")
@@ -128,18 +170,24 @@ def check_approvals(ws):
# Extract IP if present # Extract IP if present
import re import re
ips = re.findall(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', d) 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 # Check trust: if IP present, must be in TRUSTED_IPS; if no IP, untrusted approval dialog
# are likely false positives (chat content, UI text) - don't block. if ips:
if not ips: is_trusted = any(ip in TRUSTED_IPS for ip in ips)
continue else:
is_trusted = any(ip in TRUSTED_IPS for ip in ips) # 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: if is_trusted:
# Auto-approve: click "Allow once" or "Allow" # Auto-approve: click primary action or "Allow once" / "Allow"
clicked = ev(ws, """(async()=>{ clicked = ev(ws, """(async()=>{
const b = [...document.querySelectorAll('button')].find(x=>{ const b = document.querySelector('button[data-hatch-approval-primary-action="true"]') ||
const t = (x.innerText||'').toLowerCase(); [...document.querySelectorAll('button')].find(x=>{
return t.includes('allow once') || t === 'allow'; 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); } if (b) { b.click(); return 'clicked:'+b.innerText.slice(0,20); }
return 'NOTFOUND'; return 'NOTFOUND';
})()""", True) })()""", True)
+11 -1
View File
@@ -14,6 +14,7 @@ CLI:
netvm-registry.py # prints "node:port" lines for active nodes netvm-registry.py # prints "node:port" lines for active nodes
netvm-registry.py <node> # prints just the port (for bash) netvm-registry.py <node> # prints just the port (for bash)
""" """
import hashlib
import os import os
import re import re
import sys import sys
@@ -45,9 +46,12 @@ def load(registry_path=None):
port_n = int(port) port_n = int(port)
except ValueError: except ValueError:
continue 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, nodes[node] = {"netns": netns, "egress_ip": egress,
"cdp_port": port_n, "status": status, "cdp_port": port_n, "status": status,
"agent": agent} "agent": agent, "peer_ip": peer_ip}
except FileNotFoundError: except FileNotFoundError:
pass pass
return nodes return nodes
@@ -59,6 +63,12 @@ def port_for(node, registry_path=None):
return rec["cdp_port"] if rec else 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): def active_nodes(registry_path=None):
"""{node: port} for nodes with status == active.""" """{node: port} for nodes with status == active."""
return {n: r["cdp_port"] for n, r in load(registry_path).items() return {n: r["cdp_port"] for n, r in load(registry_path).items()