approvals: stale-wait cleanup, key-decision notify, TTLs, key/browser isolation
- bin/approvals.py: responded-wait filtering + auto-mark, key-decision sidechat-first notify (notified flag, --message, --allow-main-chat), TTL defaults (input 30m / browser 30m / key 2h), cross-type guard (browser actions cannot resolve key requests), sweep_expired_key_requests; restores check_node_key_request fallback in inspect_node_approvals - bin/box-ctl.py + bin/super-cli.py (approvals hunks only): --message/--allow-main-chat passthrough on allow/deny, clear/clear-all actions, def sidechat routing; restores sys.exit(1) on dismiss failure - bin/fleet-alert-check.sh: TTL-aware state_machine (EXPIRED action), auto-deny expired browser approvals (fail closed), auto-dismiss expired input waits, targeted per-agent DM for input waits - bin/job-dispatch.py + bin/gravity.py: KEY_APPROVAL excluded from auto-approval Reviewed by 5 independent reviewers (all APPROVE/APPROVE WITH NOTES); integration gate GO (17/17 tests). TUI hunks in super-cli.py intentionally excluded.
This commit is contained in:
+337
-12
@@ -31,6 +31,98 @@ except ImportError:
|
||||
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
||||
BIN_DIR = REPO_ROOT / "bin"
|
||||
CTL_LOG = REPO_ROOT / "box-ctl.jsonl"
|
||||
RESPONDED_WAITS_FILE = REPO_ROOT / ".state" / "approvals-responded.json"
|
||||
FIRST_SEEN_WAITS_FILE = REPO_ROOT / ".state" / "approvals-first-seen.json"
|
||||
|
||||
|
||||
def load_responded_waits() -> dict:
|
||||
"""Load the set of (node, task) input waits already responded to.
|
||||
|
||||
Returns {node: {task: iso_timestamp}}. Missing file -> {}.
|
||||
"""
|
||||
try:
|
||||
if RESPONDED_WAITS_FILE.exists():
|
||||
return json.loads(RESPONDED_WAITS_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_responded_waits(data: dict) -> None:
|
||||
"""Persist the responded-waits map (best effort)."""
|
||||
try:
|
||||
RESPONDED_WAITS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
RESPONDED_WAITS_FILE.write_text(json.dumps(data, indent=1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_first_seen_waits() -> dict:
|
||||
"""Load map of when input waits were first observed: {node: {task: iso_timestamp}}."""
|
||||
try:
|
||||
if FIRST_SEEN_WAITS_FILE.exists():
|
||||
return json.loads(FIRST_SEEN_WAITS_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_first_seen_waits(data: dict) -> None:
|
||||
"""Persist first-seen input waits map."""
|
||||
try:
|
||||
FIRST_SEEN_WAITS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
FIRST_SEEN_WAITS_FILE.write_text(json.dumps(data, indent=1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def is_wait_responded(node: str, task: str) -> bool:
|
||||
"""True if this (node, task) wait was already answered."""
|
||||
return task in load_responded_waits().get(node, {})
|
||||
|
||||
|
||||
def mark_wait_responded(node: str, task: str, caller: str = "approvals") -> None:
|
||||
"""Record that (node, task) has been responded to, so future
|
||||
inspections filter it out of the live input-wait list."""
|
||||
data = load_responded_waits()
|
||||
node_map = data.setdefault(node, {})
|
||||
if task not in node_map:
|
||||
node_map[task] = datetime.now(timezone.utc).isoformat()
|
||||
save_responded_waits(data)
|
||||
log_box_ctl("approval-wait-responded", name=node, caller=caller,
|
||||
extra={"task": task})
|
||||
|
||||
|
||||
def clear_node_waits(node: str = None, caller: str = "box-approvals") -> dict:
|
||||
"""Clear and dismiss all pending input waits for a specific node or all nodes."""
|
||||
target_nodes = [node] if node else VALID_NODES
|
||||
total_cleared = 0
|
||||
cleared_per_node = {}
|
||||
data = load_responded_waits()
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
for n in target_nodes:
|
||||
node_map = data.setdefault(n, {})
|
||||
n_cleared = 0
|
||||
try:
|
||||
info = inspect_node_approvals(n)
|
||||
for w in info.get("input_waits", []) or []:
|
||||
t = w.get("task")
|
||||
if t and t not in node_map:
|
||||
node_map[t] = now_iso
|
||||
n_cleared += 1
|
||||
if n_cleared:
|
||||
log_box_ctl("approval-wait-cleared", name=n, caller=caller,
|
||||
extra={"cleared_count": n_cleared})
|
||||
except Exception:
|
||||
pass
|
||||
cleared_per_node[n] = n_cleared
|
||||
total_cleared += n_cleared
|
||||
|
||||
if total_cleared:
|
||||
save_responded_waits(data)
|
||||
|
||||
return {"ok": True, "total_cleared": total_cleared, "cleared_per_node": cleared_per_node}
|
||||
|
||||
# Add BIN_DIR to sys.path
|
||||
if str(BIN_DIR) not in sys.path:
|
||||
@@ -43,6 +135,14 @@ except ImportError:
|
||||
|
||||
VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"]
|
||||
|
||||
# Approval timeout defaults (seconds).
|
||||
# Key requests are sensitive operations -- give the operator 2h to decide.
|
||||
# Input waits and browser approvals block agent work -- expire after 30m so
|
||||
# agents unblock instead of sitting indefinitely (e.g. def waited 4h+).
|
||||
KEY_REQUEST_TTL_SECONDS = 2 * 3600
|
||||
INPUT_WAIT_TTL_SECONDS = 30 * 60
|
||||
BROWSER_APPROVAL_TTL_SECONDS = 30 * 60
|
||||
|
||||
# Trusted infrastructure IPs safe for automated approval
|
||||
TRUSTED_IPS = {
|
||||
"34.139.37.135", # VM (gateway)
|
||||
@@ -95,12 +195,30 @@ def redact_sensitive(text: str) -> str:
|
||||
return out
|
||||
|
||||
|
||||
def _approval_type(action: str) -> str:
|
||||
"""Classify an approval action into its request type.
|
||||
|
||||
Types: "key" (passkey/key requests), "browser" (browser dialog
|
||||
clicks), "input" (task input replies), "other". Used so that a
|
||||
resolution only clears requests of the matching type -- a browser
|
||||
approval-allow must never resolve a pending key request.
|
||||
"""
|
||||
if action.startswith("key-approval-"):
|
||||
return "key"
|
||||
if action == "approval-reply":
|
||||
return "input"
|
||||
if action.startswith("approval-"):
|
||||
return "browser"
|
||||
return "other"
|
||||
|
||||
|
||||
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,
|
||||
"type": _approval_type(action),
|
||||
"name": name,
|
||||
"caller": caller,
|
||||
}
|
||||
@@ -118,14 +236,23 @@ def log_box_ctl(action: str, name: str = None, caller: str = "box-approvals", ex
|
||||
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."""
|
||||
def request_key_approval(node: str, reason: str = "", caller: str = "agent",
|
||||
ttl_seconds: int = None) -> dict:
|
||||
"""Register a key/passkey approval request for a node in box-ctl.jsonl.
|
||||
|
||||
ttl_seconds: how long the request stays valid (default KEY_REQUEST_TTL_SECONDS).
|
||||
After expiry the request is treated as denied; see check_node_key_request().
|
||||
"""
|
||||
reason = reason or "Operator passkey access requested"
|
||||
ttl = ttl_seconds if ttl_seconds is not None else KEY_REQUEST_TTL_SECONDS
|
||||
expires_iso = datetime.fromtimestamp(
|
||||
datetime.now(timezone.utc).timestamp() + ttl, tz=timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
log_box_ctl(
|
||||
"key-approval-request",
|
||||
name=node,
|
||||
caller=caller,
|
||||
extra={"reason": reason},
|
||||
extra={"reason": reason, "ttl_seconds": ttl, "expires_at": expires_iso},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -133,16 +260,55 @@ def request_key_approval(node: str, reason: str = "", caller: str = "agent") ->
|
||||
"status": "KEY_APPROVAL_REQUESTED",
|
||||
"reason": reason,
|
||||
"caller": caller,
|
||||
"note": "Request recorded in audit log. Operator can approve via 'box approvals allow <node>'."
|
||||
"ttl_seconds": ttl,
|
||||
"expires_at": expires_iso,
|
||||
"note": "Request recorded in audit log. Operator can approve via 'box approvals allow <node>'.",
|
||||
}
|
||||
|
||||
|
||||
def _parse_ts(ts_str: str):
|
||||
"""Parse a box-ctl.jsonl ts ('%Y-%m-%dT%H:%M:%SZ') to epoch seconds. None on failure."""
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
return dt.timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def expire_key_request(node: str, caller: str = "approval-sweeper") -> dict:
|
||||
"""Mark a node's pending key request as expired (auto-deny on TTL)."""
|
||||
log_box_ctl("key-approval-expired", name=node, caller=caller,
|
||||
extra={"note": "TTL elapsed without operator decision; treated as denied"})
|
||||
return {"ok": True, "node": node, "status": "KEY_APPROVAL_EXPIRED"}
|
||||
|
||||
|
||||
def _rec_approval_type(rec: dict) -> str:
|
||||
"""Return the approval type of a log record.
|
||||
|
||||
Prefers the explicit "type" field (present on records written after the
|
||||
type-field fix); falls back to deriving from the action name for older
|
||||
records so history keeps working.
|
||||
"""
|
||||
t = rec.get("type")
|
||||
if t:
|
||||
return t
|
||||
return _approval_type(rec.get("action", ""))
|
||||
|
||||
|
||||
def check_node_key_request(node: str) -> dict:
|
||||
"""Check if node has an active unfulfilled key approval request in box-ctl.jsonl."""
|
||||
"""Check if node has an active unfulfilled key approval request in box-ctl.jsonl.
|
||||
|
||||
Requests expire after their TTL (default KEY_REQUEST_TTL_SECONDS). An expired
|
||||
request is treated as denied: this logs a key-approval-expired event and
|
||||
returns None (no active request).
|
||||
"""
|
||||
if not CTL_LOG.exists():
|
||||
return None
|
||||
latest_req = None
|
||||
resolved = False
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
try:
|
||||
with open(CTL_LOG, "r") as f:
|
||||
for line in f:
|
||||
@@ -159,21 +325,73 @@ def check_node_key_request(node: str) -> dict:
|
||||
if act == "key-approval-request":
|
||||
latest_req = rec
|
||||
resolved = False
|
||||
elif act in ("key-approval-allow", "key-approval-deny", "approval-allow", "approval-deny"):
|
||||
elif _rec_approval_type(rec) == "key" and act in (
|
||||
"key-approval-allow", "key-approval-deny", "key-approval-expired",
|
||||
):
|
||||
# Only a KEY-type resolution clears a key request. A browser
|
||||
# approval-allow/deny must never resolve a pending key request
|
||||
# (cross-type resolution bug).
|
||||
resolved = True
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if latest_req and not resolved:
|
||||
# TTL check: explicit expires_at wins; legacy records fall back to
|
||||
# ts + default TTL.
|
||||
exp_ts = _parse_ts(latest_req.get("expires_at"))
|
||||
if exp_ts is None:
|
||||
req_ts = _parse_ts(latest_req.get("ts"))
|
||||
exp_ts = (req_ts + KEY_REQUEST_TTL_SECONDS) if req_ts else None
|
||||
if exp_ts is not None and now > exp_ts:
|
||||
# Expired: record the expiry (idempotent -- a later scan sees the
|
||||
# key-approval-expired event and treats it as resolved) and report
|
||||
# no active request.
|
||||
expire_key_request(node, caller="approval-ttl-check")
|
||||
return None
|
||||
return {
|
||||
"node": node,
|
||||
"reason": latest_req.get("reason", "Operator passkey access requested"),
|
||||
"requested_at": latest_req.get("ts", ""),
|
||||
"caller": latest_req.get("caller", ""),
|
||||
"expires_at": latest_req.get("expires_at", ""),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def sweep_expired_key_requests() -> dict:
|
||||
"""Proactively expire stale key requests across all nodes.
|
||||
|
||||
check_node_key_request() expires as a side effect when it sees a past-TTL
|
||||
request, so this sweep just triggers that check for every node. Idempotent:
|
||||
already-expired requests are skipped (the key-approval-expired event marks
|
||||
them resolved). Returns {"expired_now": [nodes expired by this sweep]}.
|
||||
"""
|
||||
expired_now = []
|
||||
if not CTL_LOG.exists():
|
||||
return {"expired_now": expired_now}
|
||||
# Snapshot of expiry-event count per node before the sweep
|
||||
def _expiry_count(node):
|
||||
n = 0
|
||||
try:
|
||||
with open(CTL_LOG, "r") as f:
|
||||
for line in f:
|
||||
try:
|
||||
rec = json.loads(line.strip())
|
||||
except Exception:
|
||||
continue
|
||||
if rec.get("name") == node and rec.get("action") == "key-approval-expired":
|
||||
n += 1
|
||||
except Exception:
|
||||
pass
|
||||
return n
|
||||
before = {node: _expiry_count(node) for node in VALID_NODES}
|
||||
for node in VALID_NODES:
|
||||
check_node_key_request(node) # side effect: expires past-TTL requests
|
||||
for node in VALID_NODES:
|
||||
if _expiry_count(node) > before[node]:
|
||||
expired_now.append(node)
|
||||
return {"expired_now": sorted(expired_now)}
|
||||
|
||||
|
||||
def get_node_connection_info(node: str) -> dict:
|
||||
"""Return peer_ip, cdp_port, and netns for a given node."""
|
||||
@@ -493,6 +711,46 @@ def inspect_node_approvals(node: str) -> dict:
|
||||
"when": w.get("when", ""),
|
||||
})
|
||||
|
||||
# Filter out waits already responded to (stale sidebar entries that
|
||||
# muse.ai never cleared). Responded set is maintained by
|
||||
# reply_node_task() and mark_wait_responded().
|
||||
responded = load_responded_waits().get(node, {})
|
||||
if responded:
|
||||
unique_waits = [w for w in unique_waits if w.get("task") not in responded]
|
||||
|
||||
# TTL check for input waits: auto-expire stale waits older than INPUT_WAIT_TTL_SECONDS
|
||||
if unique_waits:
|
||||
first_seen = load_first_seen_waits()
|
||||
node_seen = first_seen.setdefault(node, {})
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
now_iso = now_dt.isoformat()
|
||||
first_seen_changed = False
|
||||
surviving_waits = []
|
||||
|
||||
for w in unique_waits:
|
||||
t = w.get("task")
|
||||
if not t:
|
||||
continue
|
||||
if t not in node_seen:
|
||||
node_seen[t] = now_iso
|
||||
first_seen_changed = True
|
||||
surviving_waits.append(w)
|
||||
else:
|
||||
try:
|
||||
seen_dt = datetime.fromisoformat(node_seen[t])
|
||||
age_seconds = (now_dt - seen_dt).total_seconds()
|
||||
except Exception:
|
||||
age_seconds = 0
|
||||
if age_seconds >= INPUT_WAIT_TTL_SECONDS:
|
||||
# Stale wait expired! Auto-mark it responded so it never blocks again
|
||||
mark_wait_responded(node, t, caller="wait-ttl-auto-expire")
|
||||
else:
|
||||
surviving_waits.append(w)
|
||||
|
||||
if first_seen_changed:
|
||||
save_first_seen_waits(first_seen)
|
||||
unique_waits = surviving_waits
|
||||
|
||||
key_req = check_node_key_request(node)
|
||||
if key_req:
|
||||
return {
|
||||
@@ -549,7 +807,29 @@ def check_fleet_approvals(nodes: list = None) -> list:
|
||||
return results
|
||||
|
||||
|
||||
def allow_node_approval(node: str, always: bool = False, force: bool = False, caller: str = "box-approvals") -> dict:
|
||||
|
||||
def _notify_key_decision(node: str, decision: str, reason: str, message: str,
|
||||
allow_main_chat: bool = False, caller: str = "box-approvals") -> dict:
|
||||
"""Notify the waiting agent of a key-approval decision via their thread.
|
||||
|
||||
Best-effort: the decision is already recorded in the audit log by the
|
||||
caller. If notification fails, the operator must follow up manually.
|
||||
Returns the reply_node_task result dict.
|
||||
"""
|
||||
try:
|
||||
res = reply_node_task(node, message, allow_main_chat=allow_main_chat, caller=caller)
|
||||
log_box_ctl(
|
||||
f"key-approval-notify-{decision}",
|
||||
name=node,
|
||||
caller=caller,
|
||||
extra={"reason": reason, "notified": bool(res.get("ok")), "notify_error": res.get("error", "")},
|
||||
)
|
||||
return res
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "error": f"notify exception: {e}"}
|
||||
|
||||
|
||||
def allow_node_approval(node: str, always: bool = False, force: bool = False, caller: str = "box-approvals", message: str = None, allow_main_chat: bool = False) -> 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"):
|
||||
@@ -567,6 +847,16 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
"forced": force,
|
||||
},
|
||||
)
|
||||
# Execute-gap fix: notify the waiting agent. The operator performed
|
||||
# the physical key action out-of-band; the agent needs the decision
|
||||
# delivered or it stays blocked.
|
||||
notify_msg = message or (
|
||||
f"[operator] Key/passkey request APPROVED ({reason}). "
|
||||
"Operator action complete - you may proceed."
|
||||
)
|
||||
notify_res = _notify_key_decision(
|
||||
node, "allow", reason, notify_msg, allow_main_chat, caller
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
@@ -575,6 +865,8 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
"dismissed": True,
|
||||
"reason": reason,
|
||||
"title": info.get("title"),
|
||||
"notified": bool(notify_res.get("ok")),
|
||||
"notify_result": notify_res,
|
||||
}
|
||||
|
||||
if not info.get("is_trusted") and not force:
|
||||
@@ -677,7 +969,7 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
return {"ok": False, "node": node, "error": str(e)}
|
||||
|
||||
|
||||
def deny_node_approval(node: str, caller: str = "box-approvals") -> dict:
|
||||
def deny_node_approval(node: str, caller: str = "box-approvals", message: str = None, allow_main_chat: bool = False) -> 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"):
|
||||
@@ -694,6 +986,14 @@ def deny_node_approval(node: str, caller: str = "box-approvals") -> dict:
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
# Execute-gap fix: notify the waiting agent of the denial.
|
||||
notify_msg = message or (
|
||||
f"[operator] Key/passkey request DENIED ({reason}). "
|
||||
"Do not proceed with the protected action."
|
||||
)
|
||||
notify_res = _notify_key_decision(
|
||||
node, "deny", reason, notify_msg, allow_main_chat, caller
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
@@ -702,6 +1002,8 @@ def deny_node_approval(node: str, caller: str = "box-approvals") -> dict:
|
||||
"dismissed": True,
|
||||
"reason": reason,
|
||||
"title": info.get("title"),
|
||||
"notified": bool(notify_res.get("ok")),
|
||||
"notify_result": notify_res,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -861,6 +1163,12 @@ def reply_node_task(node: str, message: str, allow_main_chat: bool = False, call
|
||||
"send_res": send_res,
|
||||
},
|
||||
)
|
||||
# The wait(s) visible at reply time are now answered — record them
|
||||
# so the sidebar's stale entries stop re-alerting.
|
||||
for w in info.get("input_waits", []) or []:
|
||||
t = w.get("task")
|
||||
if t:
|
||||
mark_wait_responded(node, t, caller=caller)
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
@@ -877,11 +1185,18 @@ def reply_node_task(node: str, message: str, allow_main_chat: bool = False, call
|
||||
|
||||
|
||||
def dismiss_node_task(node: str, caller: str = "box-approvals") -> dict:
|
||||
"""Close any open task modal dialog or popup on a node."""
|
||||
"""Close any open task modal dialog or popup on a node and clear active input waits."""
|
||||
clear_res = clear_node_waits(node, caller=caller)
|
||||
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}"}
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
"result": "WAITS_CLEARED",
|
||||
"cleared_waits": clear_res.get("cleared_per_node", {}).get(node, 0),
|
||||
"warning": f"CDP unreachable ({e}), but input waits cleared",
|
||||
}
|
||||
|
||||
try:
|
||||
js_dismiss = """(() => {
|
||||
@@ -892,11 +1207,21 @@ def dismiss_node_task(node: str, caller: str = "box-approvals") -> dict:
|
||||
})()"""
|
||||
res = cdp_evaluate(ws, js_dismiss, timeout=2.0)
|
||||
ws.close()
|
||||
return {"ok": True, "node": node, "result": res}
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
"result": res,
|
||||
"cleared_waits": clear_res.get("cleared_per_node", {}).get(node, 0),
|
||||
}
|
||||
except Exception as e:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "node": node, "error": str(e)}
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
"result": "WAITS_CLEARED",
|
||||
"cleared_waits": clear_res.get("cleared_per_node", {}).get(node, 0),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user