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'."
|
"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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user