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'."
})
# 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")
+67 -19
View File
@@ -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:
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,11 +118,26 @@ 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
// 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;
}
}
// 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')) {
// Find the dialog
const els = [...document.querySelectorAll('*')].filter(el => {
const t = el.innerText || '';
return t.includes('Allow') && t.includes('to share') && t.length < 500;
@@ -106,16 +146,18 @@ def check_approvals(ws):
dialogs.push(el.innerText.slice(0,200));
}
}
// Check for other permission patterns
}
// 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 && dialogs.length === 0) {
// Might be a permission dialog
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);
})()""")
try:
@@ -128,17 +170,23 @@ 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
# 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';
+11 -1
View File
@@ -14,6 +14,7 @@ CLI:
netvm-registry.py # prints "node:port" lines for active nodes
netvm-registry.py <node> # 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()