423 lines
14 KiB
Python
423 lines
14 KiB
Python
"""Permissions tab: defaults radios, website modes, protocols, advanced.
|
|
|
|
Contracts live here (single copy): toggles.py references these
|
|
constants for addressing. Drills open sub-pages, read, and come back
|
|
via the back affordance with tab re-entry fallback. All flows are
|
|
ws-level; sessions and error shaping live in toggles.py.
|
|
"""
|
|
import re
|
|
import time
|
|
|
|
from approvals import cdp_evaluate
|
|
from hatch_menu import controls, dialog
|
|
from hatch_menu.mouse import escape, real_click
|
|
|
|
TAB = "Permissions"
|
|
ROOT_MARK = "Manage permissions"
|
|
|
|
CONNECTOR_HEADING = "Connector defaults"
|
|
WEB_HEADING = "Web access defaults"
|
|
DEFAULT_VALUES = ("auto_allow", "always_ask")
|
|
|
|
WEBSITE_MODES = ("Allow", "Ask", "Deny")
|
|
|
|
ADV_LABELS = {"transparent_proxy": "Transparent proxy",
|
|
"tls_interception": "TLS interception",
|
|
"sni_mismatch_rejection": "SNI mismatch rejection"}
|
|
|
|
# Row titles (first line of each protocol row) pinned live 2026-10-06:
|
|
# network primitives on every node checked; MCP titles kept
|
|
# defensively in case they appear on other plans/accounts.
|
|
PROTOCOL_SLUGS = {"Outbound SSH": "outbound-ssh",
|
|
"Outgoing email (SMTP)": "smtp",
|
|
"Email mailbox access (IMAP, POP3)": "imap-pop3",
|
|
"Database connections": "database",
|
|
"File transfer (FTP)": "ftp",
|
|
"External DNS lookups": "dns",
|
|
"Other TCP connections": "other-tcp",
|
|
"Other UDP traffic": "other-udp",
|
|
"Model Context Protocol servers (SSE)": "mcp-sse",
|
|
"Model Context Protocol servers (Streamable HTTP)":
|
|
"mcp-streamable",
|
|
"Agent Skills endpoints": "agent-skills",
|
|
"MCP Apps (UI extensions)": "mcp-apps",
|
|
"MCP remote OAuth": "mcp-oauth"}
|
|
|
|
JS_WEBSITES = """(() => {
|
|
const d = document.querySelector('[role="dialog"]');
|
|
if (!d) return null;
|
|
const out = [];
|
|
for (const b of d.querySelectorAll('button')) {
|
|
const m = (b.getAttribute('aria-label') || '').match(
|
|
/^Change permission mode for (.+),\\s*(Allow|Ask|Deny)$/i);
|
|
if (!m) continue;
|
|
const r = b.getBoundingClientRect();
|
|
out.push({host: m[1].trim(), mode: m[2],
|
|
x: r.x + r.width / 2, y: r.y + r.height / 2});
|
|
}
|
|
return out;
|
|
})()"""
|
|
|
|
JS_MODE_MENU = """(() => {
|
|
return Array.from(document.querySelectorAll('[role="menuitem"]'))
|
|
.map(m => ({text: (m.innerText || '').trim()}));
|
|
})()"""
|
|
|
|
JS_CLICK_MODE = """((mode) => {
|
|
const m = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
|
.find(el => (el.innerText || '').trim() === mode);
|
|
if (!m) return 'NO_MATCH';
|
|
m.click();
|
|
return 'CLICKED';
|
|
})('%s')"""
|
|
|
|
JS_PROTO_ROWS = """(() => {
|
|
const d = document.querySelector('[role="dialog"]');
|
|
if (!d) return null;
|
|
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
|
|
let el = s.parentElement, title = '', depth = 0;
|
|
while (el && el !== d && depth < 6) {
|
|
const t = (el.innerText || '').trim().split('\\n')[0] || '';
|
|
if (t) { title = t.slice(0, 80); break; }
|
|
el = el.parentElement;
|
|
depth += 1;
|
|
}
|
|
const r = s.getBoundingClientRect();
|
|
return {title: title,
|
|
checked: s.getAttribute('aria-checked') === 'true',
|
|
x: r.x + r.width / 2, y: r.y + r.height / 2};
|
|
});
|
|
})()"""
|
|
|
|
|
|
def _eval(ws, js, timeout=8.0):
|
|
try:
|
|
return cdp_evaluate(ws, js, timeout=timeout)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _stable_rows(ws, js, retries=4, pause=1.5):
|
|
"""Repeat a row read until two consecutive reads agree.
|
|
|
|
Guards mid-animation partial DOM (innerText shifts while the
|
|
sub-page slides in). Returns the agreed list, or None.
|
|
"""
|
|
last = "sentinel"
|
|
for _ in range(retries):
|
|
rows = _eval(ws, js)
|
|
if isinstance(rows, list) and rows == last:
|
|
return rows
|
|
last = rows if isinstance(rows, list) else "sentinel"
|
|
time.sleep(pause)
|
|
return last if isinstance(last, list) else None
|
|
|
|
|
|
def _slug(title):
|
|
"""Protocol slug: registry hit, else slugified, else None."""
|
|
if title in PROTOCOL_SLUGS:
|
|
return PROTOCOL_SLUGS[title]
|
|
clean = re.sub(r"[^a-z0-9]+", "-",
|
|
title.strip().lower()).strip("-")
|
|
return clean or None
|
|
|
|
|
|
def _canon_mode(mode):
|
|
"""Canonical Allow/Ask/Deny (case-insensitive); passthrough else."""
|
|
for m in WEBSITE_MODES:
|
|
if (mode or "").lower() == m.lower():
|
|
return m
|
|
return mode
|
|
|
|
|
|
def resolve_protocol(name):
|
|
"""Slug/title -> row title, None when unresolvable.
|
|
|
|
Exact slug or title first; then a unique case-insensitive
|
|
substring over titles+slugs (so 'ssh' finds Outbound SSH).
|
|
"""
|
|
if not isinstance(name, str) or not name.strip():
|
|
return None
|
|
want = name.strip().lower()
|
|
for title, slug in PROTOCOL_SLUGS.items():
|
|
if want == slug or want == title.lower():
|
|
return title
|
|
hits = [t for t, s in PROTOCOL_SLUGS.items()
|
|
if want in t.lower() or want in s]
|
|
if len(hits) == 1:
|
|
return hits[0]
|
|
return None
|
|
|
|
|
|
def _back_to_root(ws):
|
|
"""Back affordance, else tab re-entry; verify root text."""
|
|
dialog.go_back(ws)
|
|
if ROOT_MARK in (dialog.dialog_text(ws) or ""):
|
|
return True
|
|
if not dialog.goto_tab(ws, TAB):
|
|
return False
|
|
return ROOT_MARK in (dialog.dialog_text(ws) or "")
|
|
|
|
|
|
def defaults(ws):
|
|
"""Connector + web default values (each value or None)."""
|
|
if not dialog.goto_tab(ws, TAB):
|
|
return {"connector_defaults": None, "web_access": None}
|
|
heads = {CONNECTOR_HEADING.lower(): "connector_defaults",
|
|
WEB_HEADING.lower(): "web_access"}
|
|
vals = {CONNECTOR_HEADING.lower(): [], WEB_HEADING.lower(): []}
|
|
for r in controls.list_radios(ws):
|
|
h = (r.get("heading") or "").lower()
|
|
if h in vals and r.get("checked"):
|
|
vals[h].append(r.get("value"))
|
|
out = {}
|
|
for h, key in heads.items():
|
|
out[key] = vals[h][0] if len(vals[h]) == 1 else None
|
|
return out
|
|
|
|
|
|
def set_default(ws, which, value):
|
|
"""Set one defaults radio. Bool."""
|
|
if not dialog.goto_tab(ws, TAB):
|
|
return False
|
|
heading = CONNECTOR_HEADING if which == "connector_defaults" \
|
|
else WEB_HEADING
|
|
return controls.set_radio_by_heading(ws, heading, value)
|
|
|
|
|
|
def ensure_advanced(ws):
|
|
"""Expand Advanced network settings when collapsed. Bool."""
|
|
if not dialog.goto_tab(ws, TAB):
|
|
return False
|
|
labels = [s.get("label", "") for s in controls.list_switches(ws)]
|
|
if any("Transparent proxy" in lab for lab in labels):
|
|
return True
|
|
if not dialog.click_row(ws, "Advanced network settings", TAB):
|
|
return False
|
|
time.sleep(0.6)
|
|
labels = [s.get("label", "") for s in controls.list_switches(ws)]
|
|
return any("Transparent proxy" in lab for lab in labels)
|
|
|
|
|
|
def advanced(ws):
|
|
"""Advanced switch states {key: on/off/None}."""
|
|
if not ensure_advanced(ws):
|
|
return {k: None for k in ADV_LABELS}
|
|
out = {}
|
|
for key, label in ADV_LABELS.items():
|
|
state = None
|
|
for s in controls.list_switches(ws):
|
|
if label.lower() in (s.get("label") or "").lower():
|
|
state = bool(s.get("checked"))
|
|
break
|
|
out[key] = ("on" if state else "off") if state is not None \
|
|
else None
|
|
return out
|
|
|
|
|
|
def set_advanced(ws, key, on):
|
|
"""Set one advanced switch. Bool."""
|
|
if key not in ADV_LABELS or not ensure_advanced(ws):
|
|
return False
|
|
return controls.set_switch(ws, ADV_LABELS[key], on)
|
|
|
|
|
|
def _websites_raw(ws):
|
|
"""Drill into Websites; rows or None (stays on sub-page)."""
|
|
if not dialog.click_row(ws, "Websites", TAB):
|
|
return None
|
|
return _stable_rows(ws, JS_WEBSITES)
|
|
|
|
|
|
def websites(ws):
|
|
"""[{host, mode}] (back at root afterwards)."""
|
|
rows = _websites_raw(ws)
|
|
if rows is None:
|
|
return []
|
|
out = [{"host": r.get("host"), "mode": _canon_mode(r.get("mode"))}
|
|
for r in rows]
|
|
_back_to_root(ws)
|
|
return out
|
|
|
|
|
|
def website_mode(ws, host):
|
|
"""Mode for one host, or None when absent/unreadable."""
|
|
for row in websites(ws):
|
|
if (row.get("host") or "").lower() == host.lower():
|
|
return row.get("mode")
|
|
return None
|
|
|
|
|
|
def set_website_mode(ws, host, mode):
|
|
"""Set one host mode via the mode chooser. Bool.
|
|
|
|
One-way for Ask/Deny: the override row leaves the allowed list
|
|
(no add UI), so removal verifies by absence. No-op when already
|
|
there; absent hosts fail (nothing to click).
|
|
"""
|
|
if mode not in WEBSITE_MODES:
|
|
return False
|
|
rows = _websites_raw(ws)
|
|
if rows is None:
|
|
return False
|
|
target = next((r for r in rows
|
|
if (r.get("host") or "").lower() == host.lower()),
|
|
None)
|
|
if target is None:
|
|
_back_to_root(ws)
|
|
return False
|
|
if _canon_mode(target.get("mode")) == mode:
|
|
_back_to_root(ws)
|
|
return True
|
|
try:
|
|
real_click(ws, target["x"], target["y"])
|
|
except Exception:
|
|
_back_to_root(ws)
|
|
return False
|
|
time.sleep(0.8)
|
|
items = _eval(ws, JS_MODE_MENU)
|
|
texts = [(i.get("text") or "") for i in items] \
|
|
if isinstance(items, list) else []
|
|
if mode not in texts:
|
|
escape(ws)
|
|
_back_to_root(ws)
|
|
return False
|
|
if _eval(ws, JS_CLICK_MODE % mode) != "CLICKED":
|
|
escape(ws)
|
|
_back_to_root(ws)
|
|
return False
|
|
for _ in range(5):
|
|
time.sleep(2.0)
|
|
rows = _eval(ws, JS_WEBSITES)
|
|
if not isinstance(rows, list):
|
|
continue
|
|
cur = next((_canon_mode(r.get("mode")) for r in rows
|
|
if (r.get("host") or "").lower() == host.lower()),
|
|
None)
|
|
if mode in ("Ask", "Deny"):
|
|
if cur is None:
|
|
_back_to_root(ws)
|
|
return True
|
|
elif cur == mode:
|
|
_back_to_root(ws)
|
|
return True
|
|
escape(ws)
|
|
_back_to_root(ws)
|
|
return False
|
|
|
|
|
|
def _protocols_raw(ws):
|
|
"""Drill into protocols; rows or None (stays on sub-page)."""
|
|
if not dialog.click_row(ws, "Direct network protocols", TAB):
|
|
return None
|
|
return _stable_rows(ws, JS_PROTO_ROWS)
|
|
|
|
|
|
def protocols(ws):
|
|
"""[{slug, title, on}] (back at root afterwards)."""
|
|
rows = _protocols_raw(ws)
|
|
if rows is None:
|
|
return []
|
|
out = [{"slug": _slug(r.get("title", "")),
|
|
"title": r.get("title", ""),
|
|
"on": "on" if r.get("checked") else "off"} for r in rows]
|
|
_back_to_root(ws)
|
|
return out
|
|
|
|
|
|
def protocol_state(ws, title):
|
|
"""on/off for one protocol row title, None when absent."""
|
|
for row in protocols(ws):
|
|
if row.get("title") == title:
|
|
return row.get("on")
|
|
return None
|
|
|
|
|
|
def set_protocol(ws, title, on):
|
|
"""Set one protocol switch in place; readback before returning."""
|
|
rows = _protocols_raw(ws)
|
|
if rows is None:
|
|
return False
|
|
target = next((r for r in rows if r.get("title") == title), None)
|
|
if target is None:
|
|
_back_to_root(ws)
|
|
return False
|
|
want = bool(on)
|
|
if bool(target.get("checked")) == want:
|
|
_back_to_root(ws)
|
|
return True
|
|
try:
|
|
real_click(ws, target["x"], target["y"])
|
|
except Exception:
|
|
_back_to_root(ws)
|
|
return False
|
|
for _ in range(8):
|
|
time.sleep(2.0)
|
|
rows = _eval(ws, JS_PROTO_ROWS)
|
|
if not isinstance(rows, list):
|
|
continue
|
|
cur = next((r for r in rows if r.get("title") == title), None)
|
|
if cur is not None and bool(cur.get("checked")) == want:
|
|
_back_to_root(ws)
|
|
return True
|
|
_back_to_root(ws)
|
|
# In-dialog verify missed (slow commit or commit-on-close); the
|
|
# toggles-level fresh readback is the source of truth.
|
|
return False
|
|
|
|
|
|
def manage_counts(ws):
|
|
"""Manageable-row summary counts (name -> count|None)."""
|
|
if not dialog.goto_tab(ws, TAB):
|
|
return {}
|
|
text = dialog.dialog_text(ws) or ""
|
|
out = {}
|
|
for name in ("Websites", "Connectors", "Scheduled tasks",
|
|
"Direct network protocols"):
|
|
m = re.search(re.escape(name) + r"\s*(\d+)", text)
|
|
out[name] = int(m.group(1)) if m else None
|
|
return out
|
|
|
|
|
|
def scheduled_tasks(ws):
|
|
"""[{name, cadence}] (back at root afterwards; empty when none)."""
|
|
if not dialog.click_row(ws, "Scheduled tasks", TAB):
|
|
return []
|
|
time.sleep(0.6)
|
|
text = dialog.dialog_text(ws) or ""
|
|
_back_to_root(ws)
|
|
rows = []
|
|
lines = [line.strip() for line in text.splitlines()
|
|
if line.strip()]
|
|
for i, line in enumerate(lines):
|
|
if re.search(r"\b(daily|weekly|hourly|every|min)\b", line,
|
|
re.IGNORECASE) and i > 0:
|
|
rows.append({"name": lines[i - 1], "cadence": line})
|
|
return rows
|
|
|
|
|
|
def all_toggles(ws):
|
|
"""Flat map of every settable Permissions toggle (for list)."""
|
|
out = {}
|
|
defs = defaults(ws)
|
|
out["permissions.connector_defaults"] = defs.get("connector_defaults")
|
|
out["permissions.web_access"] = defs.get("web_access")
|
|
adv = advanced(ws)
|
|
for key, val in adv.items():
|
|
out["permissions.advanced." + key] = val
|
|
for row in protocols(ws):
|
|
if row.get("slug"):
|
|
out["permissions.protocols:" + row["slug"]] = row.get("on")
|
|
for row in websites(ws):
|
|
if row.get("host"):
|
|
out["permissions.websites:" + row["host"]] = row.get("mode")
|
|
return out
|
|
|
|
|
|
def describe(ws):
|
|
"""Full Permissions inventory: defaults, counts, adv, rows."""
|
|
return {"defaults": defaults(ws), "counts": manage_counts(ws),
|
|
"advanced": advanced(ws), "websites": websites(ws),
|
|
"protocols": protocols(ws),
|
|
"scheduled_tasks": scheduled_tasks(ws)}
|