adfcd2e602
- box passkey [show|fetch] (+ muse passkey): documents VM-only passkey (/srv/box/passkey.txt, fallback /etc/netvm/passkey.txt on 34.139.37.135), probes VM over SSH with graceful fallback; --json supported. No secrets on bl. - approvals: request_key_approval / check_node_key_request; KEY_APPROVAL status surfaced in `box approvals check`; allow/deny resolve + audit to box-ctl.jsonl; never auto-approved. New `box approvals request-key <node> --reason`. - box lookup (summary|fleet|threads|unread|approvals|key|docs) and docs-lookup engine with lookup_internal/ database (docs_internal symlink). - muse-tmux: non-TTY attach falls back to scrollback capture; prune NameError fix. - box/muse passthrough for tmux/muse/docs; thread list/view alias + prefix resolve. - Docs: AGENTS.md, AGENT-TOOLING.md, BOX-WEB-SURFACE-GUIDE.md, README. - Tests: key-approval + passkey tests; sync stale sidechat UUIDs and manifest name. - .gitignore runtime trackers (subagent-sessions, conversation-nudge-tracker).
903 lines
32 KiB
Python
Executable File
903 lines
32 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
approvals.py — Fleet approval detection, classification, and resolution engine.
|
|
|
|
Supports both:
|
|
1. Browser DOM element detection & interaction via CDP (the primary live surface):
|
|
- Confirmed selectors: [data-testid="approval-panel-header"],
|
|
button[data-hatch-approval-primary-action="true"] ("Allow once"),
|
|
and "Deny" / "Always allow this site" actions.
|
|
- Text fallback: "Allow <agent> to share information with <IP>?"
|
|
2. Auto-approval against TRUSTED_IPS (our infrastructure).
|
|
3. Manual operator resolution (allow once, always allow, deny).
|
|
4. Continuous watch & background integration into fleet status and loop health.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import websocket
|
|
except ImportError:
|
|
websocket = None
|
|
|
|
# Paths
|
|
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
|
BIN_DIR = REPO_ROOT / "bin"
|
|
CTL_LOG = REPO_ROOT / "box-ctl.jsonl"
|
|
|
|
# Add BIN_DIR to sys.path
|
|
if str(BIN_DIR) not in sys.path:
|
|
sys.path.insert(0, str(BIN_DIR))
|
|
|
|
try:
|
|
import netvm_registry
|
|
except ImportError:
|
|
netvm_registry = None
|
|
|
|
VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"]
|
|
|
|
# Trusted infrastructure IPs safe for automated approval
|
|
TRUSTED_IPS = {
|
|
"34.139.37.135", # VM (gateway)
|
|
"100.123.153.75", # bl (main compute)
|
|
"100.81.31.9", # VM tailnet
|
|
"1.1.1.1", # Cloudflare DNS
|
|
"1.0.0.1", # Cloudflare DNS
|
|
}
|
|
|
|
# Trusted infrastructure domains safe for automated approval
|
|
TRUSTED_DOMAINS = {
|
|
"muse-dev.online",
|
|
}
|
|
|
|
|
|
def is_trusted_target(target: str, card_text: str = "") -> bool:
|
|
"""Check if the extracted approval target is trusted infrastructure.
|
|
|
|
Fail-closed: only the parsed target (IP or hostname) is evaluated. Free-form
|
|
card text is deliberately NOT substring-matched, since an untrusted request
|
|
could mention a trusted domain in its purpose string. `card_text` is kept
|
|
for signature compatibility.
|
|
"""
|
|
if not target:
|
|
return False
|
|
target = target.strip().lower().rstrip(".")
|
|
if target in TRUSTED_IPS:
|
|
return True
|
|
for dom in TRUSTED_DOMAINS:
|
|
if target == dom or target.endswith("." + dom):
|
|
return True
|
|
return False
|
|
|
|
|
|
REDACT_PATTERNS = [
|
|
(re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+=*", re.IGNORECASE), "Bearer [REDACTED]"),
|
|
(re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9._-]{10,}", re.IGNORECASE), "[JWT-REDACTED]"),
|
|
(re.compile(r"(?i)\b(api[_-]?key|token|secret|password|auth|passkey)\s*[:=]\s*(['\"]?)([A-Za-z0-9_\-\.]{4,})\2"), r"\1: \2[REDACTED]\2"),
|
|
(re.compile(r"-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----"), "[PRIVATE-KEY-REDACTED]"),
|
|
]
|
|
|
|
|
|
def redact_sensitive(text: str) -> str:
|
|
"""Mask credentials, tokens, and passkeys in text."""
|
|
if not text or not isinstance(text, str):
|
|
return text
|
|
out = text
|
|
for pattern, repl in REDACT_PATTERNS:
|
|
out = pattern.sub(repl, out)
|
|
return out
|
|
|
|
|
|
def log_box_ctl(action: str, name: str = None, caller: str = "box-approvals", extra: dict = None):
|
|
"""Log an audit event to box-ctl.jsonl with secret redaction."""
|
|
try:
|
|
rec = {
|
|
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"action": action,
|
|
"name": name,
|
|
"caller": caller,
|
|
}
|
|
if extra:
|
|
clean_extra = {}
|
|
for k, v in extra.items():
|
|
if isinstance(v, str):
|
|
clean_extra[k] = redact_sensitive(v)
|
|
else:
|
|
clean_extra[k] = v
|
|
rec.update(clean_extra)
|
|
with open(CTL_LOG, "a") as f:
|
|
f.write(json.dumps(rec) + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def request_key_approval(node: str, reason: str = "", caller: str = "agent") -> dict:
|
|
"""Register a key/passkey approval request for a node in box-ctl.jsonl."""
|
|
reason = reason or "Operator passkey access requested"
|
|
log_box_ctl(
|
|
"key-approval-request",
|
|
name=node,
|
|
caller=caller,
|
|
extra={"reason": reason},
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"node": node,
|
|
"status": "KEY_APPROVAL_REQUESTED",
|
|
"reason": reason,
|
|
"caller": caller,
|
|
"note": "Request recorded in audit log. Operator can approve via 'box approvals allow <node>'."
|
|
}
|
|
|
|
|
|
def check_node_key_request(node: str) -> dict:
|
|
"""Check if node has an active unfulfilled key approval request in box-ctl.jsonl."""
|
|
if not CTL_LOG.exists():
|
|
return None
|
|
latest_req = None
|
|
resolved = False
|
|
try:
|
|
with open(CTL_LOG, "r") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
rec = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
if rec.get("name") != node:
|
|
continue
|
|
act = rec.get("action")
|
|
if act == "key-approval-request":
|
|
latest_req = rec
|
|
resolved = False
|
|
elif act in ("key-approval-allow", "key-approval-deny", "approval-allow", "approval-deny"):
|
|
resolved = True
|
|
except Exception:
|
|
return None
|
|
|
|
if latest_req and not resolved:
|
|
return {
|
|
"node": node,
|
|
"reason": latest_req.get("reason", "Operator passkey access requested"),
|
|
"requested_at": latest_req.get("ts", ""),
|
|
"caller": latest_req.get("caller", ""),
|
|
}
|
|
return None
|
|
|
|
|
|
|
|
def get_node_connection_info(node: str) -> dict:
|
|
"""Return peer_ip, cdp_port, and netns for a given node."""
|
|
if netvm_registry:
|
|
nodes = netvm_registry.load()
|
|
if node in nodes:
|
|
rec = nodes[node]
|
|
return {
|
|
"node": node,
|
|
"peer_ip": rec.get("peer_ip", f"10.201.87.2"),
|
|
"cdp_port": rec.get("cdp_port", 9222),
|
|
"netns": rec.get("netns", f"warp-{node}"),
|
|
}
|
|
# Deterministic fallback
|
|
import hashlib
|
|
tag = hashlib.sha256(node.encode()).hexdigest()[:8]
|
|
idx = int(tag[:3], 16) % 200 + 10
|
|
peer_ip = f"10.201.{idx}.2"
|
|
pinned = {"muse": 9410, "pip": 9420, "646": 9430, "opm": 9440, "def": 9450, "dev": 9455}
|
|
return {
|
|
"node": node,
|
|
"peer_ip": peer_ip,
|
|
"cdp_port": pinned.get(node, 9222),
|
|
"netns": f"warp-{node}",
|
|
}
|
|
|
|
|
|
def get_node_pages(node: str, timeout: float = 3.0) -> list:
|
|
"""Return all active page targets for a node."""
|
|
if websocket is None:
|
|
raise RuntimeError("websocket-client library is required")
|
|
|
|
info = get_node_connection_info(node)
|
|
peer_ip = info["peer_ip"]
|
|
port = info["cdp_port"]
|
|
|
|
urls = [
|
|
f"http://{peer_ip}:{port}/json/list",
|
|
f"http://127.0.0.1:{port}/json/list",
|
|
]
|
|
tabs = None
|
|
last_err = None
|
|
for u in urls:
|
|
try:
|
|
req = urllib.request.Request(u, headers={"User-Agent": "box-approvals/1.0"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
tabs = json.load(r)
|
|
break
|
|
except Exception as e:
|
|
last_err = e
|
|
continue
|
|
|
|
if not tabs:
|
|
raise ConnectionError(f"Could not reach CDP for {node}: {last_err}")
|
|
|
|
pages = [t for t in tabs if t.get("type") == "page"]
|
|
if not pages:
|
|
raise ConnectionError(f"No active page found for node {node}")
|
|
return pages
|
|
|
|
|
|
def get_cdp_ws(node: str, page_idx: int = 0, timeout: float = 3.0):
|
|
"""Connect to a node's browser page over CDP WebSocket."""
|
|
pages = get_node_pages(node, timeout=timeout)
|
|
if page_idx >= len(pages):
|
|
page_idx = 0
|
|
target_page = pages[page_idx]
|
|
ws_url = target_page.get("webSocketDebuggerUrl")
|
|
if not ws_url:
|
|
raise ConnectionError(f"No webSocketDebuggerUrl for node {node}")
|
|
ws = websocket.create_connection(ws_url, timeout=timeout)
|
|
return ws, target_page
|
|
|
|
|
|
def cdp_evaluate(ws, js_expr: str, await_promise: bool = False, timeout: float = 3.0):
|
|
"""Evaluate a JavaScript expression via CDP Runtime.evaluate and return the result value."""
|
|
req_id = int(time.time() * 1000) % 100000
|
|
msg = {
|
|
"id": req_id,
|
|
"method": "Runtime.evaluate",
|
|
"params": {
|
|
"expression": js_expr,
|
|
"returnByValue": True,
|
|
"awaitPromise": await_promise,
|
|
},
|
|
}
|
|
ws.send(json.dumps(msg))
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
raw = ws.recv()
|
|
resp = json.loads(raw)
|
|
if resp.get("id") == req_id:
|
|
res = resp.get("result", {})
|
|
if "exceptionDetails" in res:
|
|
return {"error": res["exceptionDetails"].get("text", "JS exception")}
|
|
return res.get("result", {}).get("value")
|
|
return None
|
|
|
|
|
|
JS_INSPECT_APPROVALS = """(() => {
|
|
// 1. Locate active approval panels
|
|
const headers = Array.from(document.querySelectorAll('[data-testid="approval-panel-header"], [data-testid*="approval"]'));
|
|
let activeCard = null;
|
|
let cardText = '';
|
|
|
|
for (const h of headers) {
|
|
let curr = h;
|
|
for (let i = 0; i < 6 && curr && curr.parentElement && curr.parentElement !== document.body; i++) {
|
|
const hasPrimary = !!curr.querySelector('button[data-hatch-approval-primary-action="true"]');
|
|
const btns = Array.from(curr.querySelectorAll('button')).map(b => (b.innerText||'').trim().toLowerCase());
|
|
const hasAllow = btns.some(t => t.includes('allow once') || t === 'allow');
|
|
const hasDeny = btns.some(t => t === 'deny');
|
|
if (hasPrimary || (hasAllow && hasDeny)) {
|
|
activeCard = curr;
|
|
cardText = curr.innerText || '';
|
|
break;
|
|
}
|
|
curr = curr.parentElement;
|
|
}
|
|
if (activeCard) break;
|
|
}
|
|
|
|
// Fallback: look for "Allow ... to share" or buttons
|
|
if (!activeCard) {
|
|
const bodyText = document.body ? document.body.innerText : '';
|
|
if (bodyText.includes('Allow') && bodyText.includes('to share')) {
|
|
const btns = Array.from(document.querySelectorAll('button'));
|
|
const allowBtn = btns.find(b => (b.innerText||'').trim().toLowerCase().includes('allow once'));
|
|
if (allowBtn) {
|
|
let curr = allowBtn;
|
|
for (let i = 0; i < 5 && curr && curr.parentElement && curr.parentElement !== document.body; i++) {
|
|
if (curr.innerText && curr.innerText.includes('Allow') && curr.innerText.includes('to share')) {
|
|
activeCard = curr;
|
|
cardText = curr.innerText;
|
|
break;
|
|
}
|
|
curr = curr.parentElement;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inspect buttons in active card
|
|
const buttons = [];
|
|
let hasAllowOnce = false;
|
|
let hasAlwaysAllow = false;
|
|
let hasDeny = false;
|
|
|
|
if (activeCard) {
|
|
const btns = Array.from(activeCard.querySelectorAll('button'));
|
|
for (const b of btns) {
|
|
const t = (b.innerText || '').trim();
|
|
const low = t.toLowerCase();
|
|
if (low) buttons.push(t);
|
|
if (low === 'allow once' || b.getAttribute('data-hatch-approval-primary-action') === 'true') hasAllowOnce = true;
|
|
if (low.includes('always allow')) hasAlwaysAllow = true;
|
|
if (low === 'deny') hasDeny = true;
|
|
}
|
|
}
|
|
|
|
// Collect historical recent approval badges from chat stream
|
|
const historyBadges = [];
|
|
const allBtns = Array.from(document.querySelectorAll('button'));
|
|
for (const b of allBtns) {
|
|
const txt = (b.innerText || '').trim();
|
|
if (txt.includes('Allowed once ·') || txt.includes('Timed out ·') || txt.includes('Site always allowed ·') || txt.includes('Allowed for this scheduled task ·')) {
|
|
const lines = txt.split('\\n');
|
|
const summary = lines[0] || '';
|
|
const statusLine = lines[lines.length - 1] || '';
|
|
historyBadges.push({ summary, status: statusLine });
|
|
}
|
|
}
|
|
|
|
// Task rows in the Activity sidebar waiting on human input
|
|
// (e.g. "Launch 5 OPM Sub-Agents\\nAsked for input to start the launch\\n5:53 pm").
|
|
const inputWaits = [];
|
|
for (const b of document.querySelectorAll('button.rounded-10, a.rounded-10')) {
|
|
const lines = (b.innerText || '').split('\\n').map(s => s.trim()).filter(Boolean);
|
|
if (lines.length >= 2 && /^(asked for (input|details)|waiting for (your )?(input|reply|approval)|needs your input)/i.test(lines[1])) {
|
|
inputWaits.push({ task: lines[0], status: lines[1], when: lines[2] || '' });
|
|
}
|
|
}
|
|
|
|
return JSON.stringify({
|
|
has_pending: !!activeCard && (hasAllowOnce || hasDeny),
|
|
card_text: cardText.slice(0, 1000),
|
|
buttons: buttons,
|
|
has_allow_once: hasAllowOnce,
|
|
has_always_allow: hasAlwaysAllow,
|
|
has_deny: hasDeny,
|
|
history: historyBadges.slice(0, 5),
|
|
input_waits: inputWaits.slice(0, 10)
|
|
});
|
|
})()"""
|
|
|
|
|
|
def inspect_node_approvals(node: str) -> dict:
|
|
"""Inspect a node across all open page targets for active approval prompts and input waits."""
|
|
try:
|
|
pages = get_node_pages(node, timeout=2.5)
|
|
except Exception as e:
|
|
key_req = check_node_key_request(node)
|
|
if key_req:
|
|
return {
|
|
"node": node,
|
|
"status": "KEY_APPROVAL",
|
|
"has_pending": True,
|
|
"title": f"Passkey requested: {key_req.get('reason')}",
|
|
"purpose": key_req.get("reason"),
|
|
"ip": "34.139.37.135",
|
|
"target": "34.139.37.135 (VM passkey)",
|
|
"is_trusted": True,
|
|
"buttons": ["Allow", "Deny"],
|
|
"has_allow_once": True,
|
|
"has_always_allow": False,
|
|
"has_deny": True,
|
|
"raw_text": f"Agent on node {node} requested passkey: {key_req.get('reason')}",
|
|
"history": [],
|
|
"input_waits": [],
|
|
"page_title": "",
|
|
"page_url": "",
|
|
"ws_url": "",
|
|
"key_request": key_req,
|
|
}
|
|
return {
|
|
"node": node,
|
|
"status": "UNREACHABLE",
|
|
"error": str(e),
|
|
"has_pending": False,
|
|
}
|
|
|
|
all_input_waits = []
|
|
first_page = pages[0]
|
|
last_err = None
|
|
|
|
for page in pages:
|
|
ws_url = page.get("webSocketDebuggerUrl")
|
|
if not ws_url:
|
|
continue
|
|
ws = None
|
|
try:
|
|
ws = websocket.create_connection(ws_url, timeout=2.0)
|
|
val_str = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=2.5)
|
|
ws.close()
|
|
ws = None
|
|
if not val_str or not isinstance(val_str, str):
|
|
continue
|
|
data = json.loads(val_str)
|
|
if data.get("input_waits"):
|
|
all_input_waits.extend(data["input_waits"])
|
|
|
|
if data.get("has_pending"):
|
|
card_text = data.get("card_text", "")
|
|
ip = None
|
|
target = None
|
|
m_t = re.search(
|
|
r"(?:connection to|share information with|contact|connect to)\s+([A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9])",
|
|
card_text,
|
|
)
|
|
if m_t:
|
|
target = m_t.group(1)
|
|
m_ip = re.search(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", target or card_text)
|
|
if m_ip:
|
|
ip = m_ip.group(0)
|
|
if not target:
|
|
target = ip
|
|
if not target:
|
|
m_domain = re.search(r"\b([a-zA-Z0-9-]+\.)+(?:online|com|net|org|io|dev)\b", card_text)
|
|
if m_domain:
|
|
target = m_domain.group(0)
|
|
|
|
lines = [line.strip() for line in card_text.split("\n") if line.strip()]
|
|
title = redact_sensitive(lines[0] if lines else "Permission request")
|
|
purpose = redact_sensitive(lines[1] if len(lines) > 1 else "")
|
|
|
|
is_trusted = is_trusted_target(target or ip, card_text)
|
|
|
|
return {
|
|
"node": node,
|
|
"status": "PENDING",
|
|
"has_pending": True,
|
|
"title": title,
|
|
"purpose": purpose,
|
|
"ip": ip,
|
|
"target": target or ip or "-",
|
|
"is_trusted": is_trusted,
|
|
"buttons": data.get("buttons", []),
|
|
"has_allow_once": data.get("has_allow_once", False),
|
|
"has_always_allow": data.get("has_always_allow", False),
|
|
"has_deny": data.get("has_deny", False),
|
|
"raw_text": redact_sensitive(card_text),
|
|
"history": data.get("history", []),
|
|
"input_waits": all_input_waits,
|
|
"page_title": page.get("title", ""),
|
|
"page_url": page.get("url", ""),
|
|
"ws_url": ws_url,
|
|
}
|
|
except Exception as e:
|
|
last_err = e
|
|
if ws:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# Deduplicate input waits by task name
|
|
unique_waits = []
|
|
seen_tasks = set()
|
|
for w in all_input_waits:
|
|
t_name = redact_sensitive(w.get("task", ""))
|
|
status_text = redact_sensitive(w.get("status", ""))
|
|
if t_name not in seen_tasks:
|
|
seen_tasks.add(t_name)
|
|
unique_waits.append({
|
|
"task": t_name,
|
|
"status": status_text,
|
|
"when": w.get("when", ""),
|
|
})
|
|
|
|
key_req = check_node_key_request(node)
|
|
if key_req:
|
|
return {
|
|
"node": node,
|
|
"status": "KEY_APPROVAL",
|
|
"has_pending": True,
|
|
"title": f"Passkey requested: {key_req.get('reason')}",
|
|
"purpose": key_req.get("reason"),
|
|
"ip": "34.139.37.135",
|
|
"target": "34.139.37.135 (VM passkey)",
|
|
"is_trusted": True,
|
|
"buttons": ["Allow", "Deny"],
|
|
"has_allow_once": True,
|
|
"has_always_allow": False,
|
|
"has_deny": True,
|
|
"raw_text": f"Agent on node {node} requested passkey: {key_req.get('reason')}",
|
|
"history": [],
|
|
"input_waits": unique_waits,
|
|
"page_title": first_page.get("title", ""),
|
|
"page_url": first_page.get("url", ""),
|
|
"ws_url": first_page.get("webSocketDebuggerUrl", ""),
|
|
"key_request": key_req,
|
|
}
|
|
|
|
status = "INPUT_WAIT" if unique_waits else ("ERROR" if last_err and not first_page else "CLEAR")
|
|
return {
|
|
"node": node,
|
|
"status": status,
|
|
"has_pending": False,
|
|
"title": "No pending approvals",
|
|
"purpose": "",
|
|
"ip": None,
|
|
"target": "-",
|
|
"is_trusted": False,
|
|
"buttons": [],
|
|
"has_allow_once": False,
|
|
"has_always_allow": False,
|
|
"has_deny": False,
|
|
"raw_text": "",
|
|
"history": [],
|
|
"input_waits": unique_waits,
|
|
"page_title": first_page.get("title", ""),
|
|
"page_url": first_page.get("url", ""),
|
|
"ws_url": first_page.get("webSocketDebuggerUrl", ""),
|
|
}
|
|
|
|
|
|
def check_fleet_approvals(nodes: list = None) -> list:
|
|
"""Check approval status across the fleet."""
|
|
target_nodes = nodes or VALID_NODES
|
|
results = []
|
|
for node in target_nodes:
|
|
results.append(inspect_node_approvals(node))
|
|
return results
|
|
|
|
|
|
def allow_node_approval(node: str, always: bool = False, force: bool = False, caller: str = "box-approvals") -> dict:
|
|
"""Approve a pending approval on a node (click 'Allow once' or 'Always allow this site')."""
|
|
info = inspect_node_approvals(node)
|
|
if not info.get("has_pending"):
|
|
return {"ok": False, "node": node, "error": "No pending approval dialog found on node"}
|
|
|
|
if info.get("status") == "KEY_APPROVAL":
|
|
key_req = info.get("key_request") or check_node_key_request(node) or {}
|
|
reason = key_req.get("reason", info.get("purpose", ""))
|
|
log_box_ctl(
|
|
"key-approval-allow",
|
|
name=node,
|
|
caller=caller,
|
|
extra={
|
|
"reason": reason,
|
|
"forced": force,
|
|
},
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"node": node,
|
|
"type": "key_approval",
|
|
"decision": "allow",
|
|
"dismissed": True,
|
|
"reason": reason,
|
|
"title": info.get("title"),
|
|
}
|
|
|
|
if not info.get("is_trusted") and not force:
|
|
target = info.get("ip") or "unrecognized target"
|
|
return {
|
|
"ok": False,
|
|
"node": node,
|
|
"error": f"Untrusted origin ({target}). Human review required. Use --force to override.",
|
|
"approval": info,
|
|
}
|
|
|
|
try:
|
|
ws_url = info.get("ws_url")
|
|
if ws_url:
|
|
ws = websocket.create_connection(ws_url, timeout=3.0)
|
|
else:
|
|
ws, _ = get_cdp_ws(node, timeout=3.0)
|
|
except Exception as e:
|
|
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
|
|
|
try:
|
|
if always:
|
|
js_click = """(() => {
|
|
const btns = Array.from(document.querySelectorAll('button'));
|
|
let btn = btns.find(b => (b.innerText||'').toLowerCase().includes('always allow'));
|
|
if (btn) {
|
|
btn.click();
|
|
return 'CLICKED_ALWAYS';
|
|
}
|
|
btn = document.querySelector('button[data-hatch-approval-primary-action="true"]');
|
|
if (!btn) {
|
|
btn = btns.find(b => (b.innerText||'').toLowerCase().includes('allow once') || (b.innerText||'').trim().toLowerCase() === 'allow');
|
|
}
|
|
if (btn) {
|
|
btn.click();
|
|
return 'CLICKED_PRIMARY_FALLBACK';
|
|
}
|
|
return 'NOT_FOUND';
|
|
})()"""
|
|
else:
|
|
js_click = """(() => {
|
|
const primary = document.querySelector('button[data-hatch-approval-primary-action="true"]');
|
|
if (primary) {
|
|
primary.click();
|
|
return 'CLICKED_PRIMARY';
|
|
}
|
|
const btns = Array.from(document.querySelectorAll('button'));
|
|
const btn = btns.find(b => {
|
|
const t = (b.innerText||'').trim().toLowerCase();
|
|
return t === 'allow once' || t === 'allow';
|
|
});
|
|
if (btn) {
|
|
btn.click();
|
|
return 'CLICKED_ALLOW';
|
|
}
|
|
return 'NOT_FOUND';
|
|
})()"""
|
|
|
|
click_res = cdp_evaluate(ws, js_click, timeout=3.0)
|
|
|
|
# Verify dismissal
|
|
time.sleep(0.8)
|
|
js_verify = """(() => {
|
|
const primary = document.querySelector('button[data-hatch-approval-primary-action="true"]');
|
|
if (primary) return 'STILL_PRESENT';
|
|
const headers = document.querySelectorAll('[data-testid="approval-panel-header"]');
|
|
return headers.length === 0 ? 'DISMISSED' : 'STILL_PRESENT';
|
|
})()"""
|
|
verify_res = cdp_evaluate(ws, js_verify, timeout=2.0)
|
|
ws.close()
|
|
|
|
dismissed = verify_res == "DISMISSED"
|
|
mode = "always" if always else "allow_once"
|
|
log_box_ctl(
|
|
"approval-allow",
|
|
name=node,
|
|
caller=caller,
|
|
extra={
|
|
"decision": mode,
|
|
"target_ip": info.get("ip"),
|
|
"dismissed": dismissed,
|
|
"forced": force,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"node": node,
|
|
"decision": mode,
|
|
"click_result": click_res,
|
|
"dismissed": dismissed,
|
|
"target_ip": info.get("ip"),
|
|
"title": info.get("title"),
|
|
}
|
|
except Exception as e:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
return {"ok": False, "node": node, "error": str(e)}
|
|
|
|
|
|
def deny_node_approval(node: str, caller: str = "box-approvals") -> dict:
|
|
"""Deny a pending approval on a node (click 'Deny' or deny key request)."""
|
|
info = inspect_node_approvals(node)
|
|
if not info.get("has_pending"):
|
|
return {"ok": False, "node": node, "error": "No pending approval dialog found on node"}
|
|
|
|
if info.get("status") == "KEY_APPROVAL":
|
|
key_req = info.get("key_request") or check_node_key_request(node) or {}
|
|
reason = key_req.get("reason", info.get("purpose", ""))
|
|
log_box_ctl(
|
|
"key-approval-deny",
|
|
name=node,
|
|
caller=caller,
|
|
extra={
|
|
"reason": reason,
|
|
},
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"node": node,
|
|
"type": "key_approval",
|
|
"decision": "deny",
|
|
"dismissed": True,
|
|
"reason": reason,
|
|
"title": info.get("title"),
|
|
}
|
|
|
|
try:
|
|
ws_url = info.get("ws_url")
|
|
if ws_url:
|
|
ws = websocket.create_connection(ws_url, timeout=3.0)
|
|
else:
|
|
ws, _ = get_cdp_ws(node, timeout=3.0)
|
|
except Exception as e:
|
|
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
|
|
|
try:
|
|
js_deny = """(() => {
|
|
const btns = Array.from(document.querySelectorAll('button'));
|
|
const btn = btns.find(b => (b.innerText||'').trim().toLowerCase() === 'deny');
|
|
if (btn) {
|
|
btn.click();
|
|
return 'CLICKED_DENY';
|
|
}
|
|
return 'NOT_FOUND';
|
|
})()"""
|
|
click_res = cdp_evaluate(ws, js_deny, timeout=3.0)
|
|
|
|
# Verify dismissal
|
|
time.sleep(0.8)
|
|
js_verify = """(() => {
|
|
const headers = document.querySelectorAll('[data-testid="approval-panel-header"]');
|
|
return headers.length === 0 ? 'DISMISSED' : 'STILL_PRESENT';
|
|
})()"""
|
|
verify_res = cdp_evaluate(ws, js_verify, timeout=2.0)
|
|
ws.close()
|
|
|
|
dismissed = verify_res == "DISMISSED"
|
|
log_box_ctl(
|
|
"approval-deny",
|
|
name=node,
|
|
caller=caller,
|
|
extra={
|
|
"decision": "deny",
|
|
"target_ip": info.get("ip"),
|
|
"dismissed": dismissed,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"node": node,
|
|
"decision": "deny",
|
|
"click_result": click_res,
|
|
"dismissed": dismissed,
|
|
"target_ip": info.get("ip"),
|
|
}
|
|
except Exception as e:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
return {"ok": False, "node": node, "error": str(e)}
|
|
|
|
|
|
def auto_approve_fleet(nodes: list = None, always: bool = True, caller: str = "box-approvals") -> dict:
|
|
"""Scan fleet nodes and automatically approve any requests to TRUSTED_IPS with Always Allow."""
|
|
fleet = check_fleet_approvals(nodes)
|
|
approved = []
|
|
untrusted = []
|
|
clear = []
|
|
|
|
for item in fleet:
|
|
node = item["node"]
|
|
if item.get("has_pending"):
|
|
if item.get("status") == "KEY_APPROVAL":
|
|
# Key approvals require explicit operator decision, never auto-approve
|
|
untrusted.append(item)
|
|
elif item.get("is_trusted"):
|
|
res = allow_node_approval(node, always=always, caller=caller)
|
|
approved.append({
|
|
"node": node,
|
|
"target_ip": item.get("ip"),
|
|
"title": item.get("title"),
|
|
"decision": "always" if always else "allow_once",
|
|
"res": res,
|
|
})
|
|
else:
|
|
untrusted.append(item)
|
|
else:
|
|
clear.append(node)
|
|
|
|
return {
|
|
"ok": True,
|
|
"auto_approved": approved,
|
|
"untrusted_pending": untrusted,
|
|
"clear_nodes": clear,
|
|
}
|
|
|
|
|
|
def reply_node_task(node: str, message: str, allow_main_chat: bool = False, caller: str = "box-approvals") -> dict:
|
|
"""Send an operator reply into the waiting agent's thread to answer an input prompt."""
|
|
info = inspect_node_approvals(node)
|
|
page_url = info.get("page_url", "")
|
|
page_title = info.get("page_title", "")
|
|
ws_url = info.get("ws_url")
|
|
|
|
# Check if target is Main Chat
|
|
# Main chat signatures: not sidechat and (no /thread/ or title contains 'Chat —')
|
|
is_main = "sidechat" not in page_url.lower() and ("/thread/" not in page_url or "chat —" in page_title.lower())
|
|
if is_main and not allow_main_chat:
|
|
return {
|
|
"ok": False,
|
|
"node": node,
|
|
"error": "Active thread appears to be Main Chat. Refusing reply by sidechat-first policy. Pass --allow-main-chat to confirm intentional operator override.",
|
|
"page_title": page_title,
|
|
"page_url": page_url,
|
|
}
|
|
|
|
try:
|
|
if ws_url:
|
|
ws = websocket.create_connection(ws_url, timeout=3.0)
|
|
else:
|
|
ws, _ = get_cdp_ws(node, timeout=3.0)
|
|
except Exception as e:
|
|
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
|
|
|
try:
|
|
msg_esc = message.replace('\\', '\\\\').replace('`', '\\`').replace('$', '\\$').replace('"', '\\"')
|
|
js_type_and_send = f"""(async() => {{
|
|
const input = document.querySelector('[contenteditable="true"]') ||
|
|
document.querySelector('textarea[placeholder*="Message"]') ||
|
|
document.querySelector('textarea');
|
|
if (!input) return 'NO_INPUT';
|
|
input.focus();
|
|
document.execCommand('insertText', false, "{msg_esc}");
|
|
await new Promise(r => setTimeout(r, 400));
|
|
const sendBtn = Array.from(document.querySelectorAll('button')).find(b => {{
|
|
const a = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
const t = (b.innerText || '').toLowerCase();
|
|
return a.includes('send') || t === 'send';
|
|
}});
|
|
if (sendBtn && !sendBtn.disabled) {{
|
|
sendBtn.click();
|
|
return 'CLICKED_SEND';
|
|
}}
|
|
const ke = new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true}});
|
|
input.dispatchEvent(ke);
|
|
return 'ENTER_SENT';
|
|
}})()"""
|
|
send_res = cdp_evaluate(ws, js_type_and_send, await_promise=True, timeout=5.0)
|
|
ws.close()
|
|
|
|
log_box_ctl(
|
|
"approval-reply",
|
|
name=node,
|
|
caller=caller,
|
|
extra={
|
|
"message_len": len(message),
|
|
"page_url": page_url,
|
|
"allow_main_chat": allow_main_chat,
|
|
"send_res": send_res,
|
|
},
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"node": node,
|
|
"send_result": send_res,
|
|
"page_title": page_title,
|
|
"page_url": page_url,
|
|
}
|
|
except Exception as e:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
return {"ok": False, "node": node, "error": str(e)}
|
|
|
|
|
|
def dismiss_node_task(node: str, caller: str = "box-approvals") -> dict:
|
|
"""Close any open task modal dialog or popup on a node."""
|
|
try:
|
|
ws, _ = get_cdp_ws(node, timeout=3.0)
|
|
except Exception as e:
|
|
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
|
|
|
try:
|
|
js_dismiss = """(() => {
|
|
const close = document.querySelector('[aria-label="Close"], button[data-slot="dialog-close"]');
|
|
if (close) { close.click(); return 'CLICKED_CLOSE'; }
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true}));
|
|
return 'ESCAPE_SENT';
|
|
})()"""
|
|
res = cdp_evaluate(ws, js_dismiss, timeout=2.0)
|
|
ws.close()
|
|
return {"ok": True, "node": node, "result": res}
|
|
except Exception as e:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
return {"ok": False, "node": node, "error": str(e)}
|
|
|