feat(approvals): integrate approval-hold detection and auto-remediation into fleet alert, loop diagnostics, and muse API
This commit is contained in:
@@ -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")
|
||||
|
||||
+83
-35
@@ -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)
|
||||
|
||||
+11
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user