"""Page-level egress policy for AI agents: decides, per request, whether an agent may send it. Layers, in order (the same order as the AI Agent Allowlist service): 1. host list - named hosts that are denied or flagged whatever the page 2. page types - the domain's verified page URLs (needs the database, see Guard(api_key=...)) 3. URL rules - regular expressions that recognise risky endpoints on any domain 4. default - reads (GET, HEAD) pass, every other method is denied The free edition ships 3 URL rules (login, signup, password reset) and 2 hosts (cloud metadata). The full rule set (40 rules, 62 hosts) and the page-type database load through the same interface. """ import csv import ipaddress as _ipaddress import json import os import re import threading import time import urllib.parse import urllib.request from dataclasses import dataclass, field from urllib.parse import urlsplit DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") API = "https://www.aiagentallowlist.com/api/check" READ_METHODS = ("GET", "HEAD") # verified page types an agent must not act on (layer 2 denies them outright) DENY_TYPES = {"login", "signup", "password_reset", "cart", "checkout", "upload", "post_create", "comment", "subscribe"} class EgressDenied(Exception): """Raised by the client integrations when a request is denied.""" def __init__(self, verdict): super().__init__("egress denied: %s %s (%s: %s)" % (verdict.method, verdict.url, verdict.layer, verdict.rule)) self.verdict = verdict @dataclass class Verdict: decision: str # "allow", "deny" or "flag" layer: str # "high_value_hosts", "page_type_db", "rules" or "default" rule: str # rule id, host pattern, page type or default reason url: str method: str page_type: str = "" note: str = "" extra: dict = field(default_factory=dict) @property def allowed(self): return self.decision == "allow" def as_dict(self): d = {"decision": self.decision, "layer": self.layer, "rule": self.rule, "url": self.url, "method": self.method, "page_type": self.page_type, "note": self.note} d.update(self.extra) return d def load_rules(path): rules = [] with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if line: r = json.loads(line) r["_rx"] = re.compile(r["url_regex"], re.I) rules.append(r) return rules def load_hosts(path): exact, wild = {}, [] with open(path, encoding="utf-8") as fh: for row in csv.DictReader(fh): p = (row.get("host_pattern") or "").strip().lower() if not p: continue if p.startswith("*."): wild.append((p[2:], row)) else: exact[p] = row return exact, wild def _norm_url(u): u = (u or "").strip().lower() u = re.sub(r"^[a-z]+://", "", u) if u.startswith("www."): u = u[4:] return u.rstrip("/") def _canon_host(host: str) -> str: """Host as a client would resolve it: lower case, no trailing dot, and every IPv4 notation (2852039166, 0xA9FEA9FE, 0251.0376.0251.0376, IPv4-mapped IPv6) as dotted quad.""" h = (host or "").strip().lower().rstrip(".") if h.startswith("[") and h.endswith("]"): h = h[1:-1] if ":" in h: try: a = _ipaddress.IPv6Address(h.split("%", 1)[0]) except ValueError: return h if a.ipv4_mapped: return str(a.ipv4_mapped) if a.packed[:12] == bytes(12) and int(a) > 1: # IPv4-compatible ::a.b.c.d return str(_ipaddress.IPv4Address(a.packed[12:])) return a.compressed if not h or not re.fullmatch(r"[0-9a-fx.]+", h) or not re.search(r"[0-9]", h): return h parts = h.split(".") if len(parts) > 4: return h nums = [] for part in parts: try: if part.startswith("0x"): n = int(part[2:] or "0", 16) elif len(part) > 1 and part.startswith("0"): n = int(part, 8) else: n = int(part, 10) except ValueError: return h nums.append(n) widths = {1: [32], 2: [8, 24], 3: [8, 8, 16], 4: [8, 8, 8, 8]}[len(nums)] if any(n >= (1 << w) for n, w in zip(nums, widths)): return h value = 0 for n, w in zip(nums, widths): value = (value << w) | n return str(_ipaddress.IPv4Address(value)) _UNRESERVED = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") def _decode_unreserved(s: str) -> str: """RFC 3986 6.2.2.2: %XX of an unreserved character is the character itself (/%6Cogin is /login). Reserved characters (%2F, %3A, %3F...) stay encoded, so URLs nested in parameters are not unpacked.""" return re.sub(r"%([0-9A-Fa-f]{2})", lambda m: chr(int(m.group(1), 16)) if chr(int(m.group(1), 16)) in _UNRESERVED else m.group(0), s) def _path_variants(pathq: str) -> list: """The path as sent, plus the forms a server acts on: unreserved characters decoded, and without ;parameters in path segments (/login;jsessionid=1 is /login).""" out = [pathq] d = _decode_unreserved(pathq) path, sep, query = d.partition("?") stripped = re.sub(r";[^/]*", "", path) + sep + query for v in (d, stripped): if v not in out: out.append(v) return out def _registrable(host): h = host[4:] if host.startswith("www.") else host return h class PageTypeAPI: """Page types from the AI Agent Allowlist database, one cached lookup per domain. Uses the public API (https://www.aiagentallowlist.com/api-docs.php); each new domain costs one lookup.""" def __init__(self, api_key, ttl=86400, timeout=10, endpoint=API): self.api_key, self.ttl, self.timeout, self.endpoint = api_key, ttl, timeout, endpoint self._cache, self._lock = {}, threading.Lock() def lookup(self, host): dom = _registrable(host) now = time.time() with self._lock: hit = self._cache.get(dom) if hit and now - hit[0] < self.ttl: return hit[1] req = urllib.request.Request(self.endpoint + "?" + urllib.parse.urlencode({"url": dom}), headers={"X-API-Key": self.api_key, "User-Agent": "agent-egress-guard/0.2.0"}) try: with urllib.request.urlopen(req, timeout=self.timeout) as r: data = json.loads(r.read().decode("utf-8")) res = {"found": bool(data.get("found")), "page_types": data.get("page_types") or {}} except Exception: res = None # lookup failed: evaluate without page types, do not cache if res is not None: with self._lock: self._cache[dom] = (now, res) return res class Guard: """Evaluate requests against the egress policy. Guard() free edition (3 rules, 2 hosts) Guard(rules_path=..., hosts_path=...) full rule set from a licensed rules file Guard(api_key=...) adds the page-type database (verified login, signup, checkout... URLs) strict=True with the database: also deny reads to domains it does not know """ def __init__(self, rules_path=None, hosts_path=None, api_key=None, strict=False, page_types=None): self.rules = load_rules(rules_path or os.path.join(DATA, "rules.jsonl")) self.hosts_exact, self.hosts_wild = load_hosts(hosts_path or os.path.join(DATA, "hosts.csv")) self.provider = page_types or (PageTypeAPI(api_key) if api_key else None) self.strict = strict @property def edition(self): full = len(self.rules) >= 40 and len(self.hosts_exact) + len(self.hosts_wild) >= 60 return ("full rules" if full else "free rules") + (" + page-type database" if self.provider else "") def check(self, url, method="GET"): info = None if self.provider: host = _canon_host(urlsplit(url if "://" in url else "https://" + url).hostname or "") info = self.provider.lookup(host) if host else None v = self.evaluate(url, method, (info or {}).get("page_types") or {}) if (self.strict and info is not None and not info.get("found") and v.decision == "allow" and v.layer == "default"): v = Verdict("deny", "default", "unknown_domain", v.url, v.method, note="strict mode: domain unknown to the page-type database") return v def evaluate(self, url, method="GET", page_types=None): """Verdict for one full URL, given the domain's verified page types (dict type -> URL).""" if "://" not in url: url = "https://" + url sp = urlsplit(url) host = _canon_host(sp.hostname or "") host_nw = host[4:] if host.startswith("www.") else host pathq = sp.path + (("?" + sp.query) if sp.query else "") variants = _path_variants(pathq) method = (method or "GET").upper() # layer 1: host list. A deny ends the evaluation; a flag defers to the more specific layers. hit = self.hosts_exact.get(host) or self.hosts_exact.get(host_nw) if not hit: for suf, row in self.hosts_wild: if host == suf or host.endswith("." + suf): hit = row break host_flag = None if hit: if (hit.get("default_verdict") or "deny") == "deny": return Verdict("deny", "high_value_hosts", hit.get("host_pattern"), url, method, hit.get("page_type") or "", hit.get("note") or "") host_flag = hit.get("host_pattern") # layer 2: the domain's verified page URLs. A deny type decides here; a read type allows # only a GET/HEAD, and only when no URL rule matches (a label must not override a rule). nus = {_norm_url(url)} if len(variants) > 1: # encoded or ;param form: also match its decoded form nus.add(_norm_url(host + variants[-1] + (("#" + sp.fragment) if sp.fragment else ""))) db_read = None for ptype, purl in (page_types or {}).items(): if _norm_url(purl) in nus: extra = {"host_flag": host_flag} if host_flag else {} if ptype in DENY_TYPES: return Verdict("deny", "page_type_db", ptype, url, method, ptype, "verified %s URL for this domain" % ptype, extra) db_read = Verdict("allow", "page_type_db", ptype, url, method, ptype, "verified %s URL for this domain" % ptype, extra) break # layer 3: URL rules (any domain) for r in self.rules: if any(r["_rx"].search(v) or r["_rx"].search(host + v) for v in variants): extra = {} if db_read: extra["db_label"] = db_read.rule if host_flag: extra["host_flag"] = host_flag return Verdict(r.get("default_verdict", "deny"), "rules", r.get("id"), url, method, r.get("page_type") or "", r.get("note") or "", extra) if db_read and method in READ_METHODS: return db_read if host_flag: return Verdict("flag", "high_value_hosts", host_flag, url, method, note="flagged host") # layer 4: default. Reads pass, writes are denied. if method in READ_METHODS: return Verdict("allow", "default", "read_unmatched", url, method, note="no deny rule matched; read methods pass") return Verdict("deny", "default", "unclassified_write", url, method, note="write method to a URL no layer classifies: default deny")