#!/usr/bin/env python3 """readiness-l0.py — reference tester for "Agent-commerce readiness, Layer 0: reachable" (draft 0.1). CC0. Python standard library only; curl and node are used as extra client profiles IF they are on PATH, and reported UNOBSERVED if they are not. usage: readiness-l0.py targets.json [--json out.json] targets.json: {"commerce": [{"url": "...", "method": "POST", "body": "{}", "source": "where the business published this URL"}], "documents": [{"url": "https://host/llms.txt", "source": "..."}]} /robots.txt of every commerce host is added to the documents by the tester. Result words: PASS, FAIL, UNOBSERVED. A timeout, a tester-side error or a client that could not run is UNOBSERVED, never PASS. Any UNOBSERVED and no FAIL => the layer is INCOMPLETE. A URL the tester guessed is out of scope: every target carries its source, and the report prints it. """ import json, os, shutil, socket, ssl, subprocess, sys, time, urllib.request, urllib.error, urllib.robotparser from urllib.parse import urlsplit TIMEOUT = 20 BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" CHALLENGE_MARKS = ("cf-chl", "challenge-platform", "/cdn-cgi/challenge", "g-recaptcha", "h-captcha", "just a moment", "enable javascript and cookies") def now(): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *a, **k): return None # a redirect is an answer; report it, do not follow it def via_urllib(url, method, body, headers, ua=None): h = dict(headers) if ua: h["User-Agent"] = ua req = urllib.request.Request(url, data=(body.encode() if body is not None else None), method=method, headers=h) op = urllib.request.build_opener(NoRedirect) try: r = op.open(req, timeout=TIMEOUT); code, hd, b = r.status, r.headers, r.read(4096) except urllib.error.HTTPError as e: code, hd, b = e.code, e.headers, e.read(4096) except Exception as e: return {"status": None, "error": f"{type(e).__name__}: {e}"[:200]} return {"status": code, "ctype": (hd.get("Content-Type") or "").lower(), "allow": hd.get("Allow"), "body": b.decode("utf-8", "replace")} def via_curl(url, method, body, headers): if not shutil.which("curl"): return {"status": None, "error": "curl not on PATH"} cmd = ["curl", "-s", "-m", str(TIMEOUT), "-X", method, "-D", "-", "-o", "-", url] for k, v in headers.items(): cmd += ["-H", f"{k}: {v}"] if body is not None: cmd += ["--data-raw", body] try: p = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT + 5) except Exception as e: return {"status": None, "error": f"{type(e).__name__}"} if p.returncode != 0: return {"status": None, "error": f"curl exit {p.returncode}"} raw = p.stdout.decode("utf-8", "replace"); head, _, rest = raw.partition("\r\n\r\n") while rest.startswith("HTTP/"): head, _, rest = rest.partition("\r\n\r\n") # 1xx interim responses lines = head.split("\r\n"); hd = {} for l in lines[1:]: k, _, v = l.partition(":"); hd[k.strip().lower()] = v.strip() try: code = int(lines[0].split()[1]) except Exception: return {"status": None, "error": "unparsed status line"} return {"status": code, "ctype": hd.get("content-type", "").lower(), "allow": hd.get("allow"), "body": rest[:4096]} NODE_SRC = """const [u,m,b,h]=JSON.parse(process.argv[1]); fetch(u,{method:m,body:b===null?undefined:b,headers:h,redirect:'manual',signal:AbortSignal.timeout(%d)}).then(async r=>{ const t=(await r.text()).slice(0,4096);console.log(JSON.stringify({status:r.status,ctype:(r.headers.get('content-type')||'').toLowerCase(),allow:r.headers.get('allow'),body:t})); }).catch(e=>console.log(JSON.stringify({status:null,error:String(e&&e.cause&&e.cause.code||e).slice(0,200)})));""" % (TIMEOUT * 1000) def via_node(url, method, body, headers): if not shutil.which("node"): return {"status": None, "error": "node not on PATH"} try: p = subprocess.run(["node", "-e", NODE_SRC, json.dumps([url, method, body, headers])], capture_output=True, timeout=TIMEOUT + 10) return json.loads(p.stdout.decode() or "{}") or {"status": None, "error": "no output"} except Exception as e: return {"status": None, "error": f"{type(e).__name__}"} PROFILES = [ ("browser-ua", lambda u, m, b, h: via_urllib(u, m, b, h, ua=BROWSER_UA)), ("python-urllib", lambda u, m, b, h: via_urllib(u, m, b, h)), ("curl", via_curl), ("node-fetch", via_node), ] PACE = 1.0 # seconds between requests: the tester should not be the reason a limiter fires def limited(v): """A 429 is the rate limiter's answer, not the door's. What the door says to this client was not observed — UNOBSERVED, never PASS and never FAIL (added 2026-09-17: the first run of this script on its author's own doors hit the author's own limiter and printed a false L0-2 FAIL, one profile 429 against three 402s).""" if v.get("status") == 429: return {"status": None, "error": "rate limited (429) — the door's own answer was not observed", "limited": True, "ctype": v.get("ctype"), "body": v.get("body")} return v def ask(t, method=None, accept=None): m = method or t["method"]; body = t.get("body") if m in ("POST", "PUT", "PATCH") else None h = {} if body is not None: h["Content-Type"] = t.get("content_type", "application/json") if accept: h["Accept"] = accept out = {} for name, fn in PROFILES: out[name] = limited(fn(t["url"], m, body, h)); time.sleep(PACE) return out def l0_1(host): try: ctx = ssl.create_default_context() with socket.create_connection((host, 443), timeout=TIMEOUT) as s: with ctx.wrap_socket(s, server_hostname=host) as tls: return {"result": "PASS", "detail": f"resolved, connected, certificate verified ({tls.version()})"} except (socket.timeout, TimeoutError) as e: return {"result": "UNOBSERVED", "detail": "timeout"} except (socket.gaierror, ConnectionRefusedError, ssl.SSLError, ssl.CertificateError) as e: return {"result": "FAIL", "detail": f"{type(e).__name__}: {e}"[:200]} except Exception as e: return {"result": "UNOBSERVED", "detail": f"{type(e).__name__}: {e}"[:200]} def parity(ans, want_2xx=False): codes = {k: v.get("status") for k, v in ans.items()} missing = [k for k, c in codes.items() if c is None] seen = {c for c in codes.values() if c is not None} if len(seen) > 1: return "FAIL", codes, "status differs across client profiles" if want_2xx and seen and not all(200 <= c < 300 for c in seen): return "FAIL", codes, "document is not 2xx" if missing: return "UNOBSERVED", codes, "no answer for: " + ", ".join(f"{k} ({ans[k].get('error')})" for k in missing) return "PASS", codes, "" def l0_3(ans): bad, missing = [], [] for k, v in ans.items(): c = v.get("status") if c is None and v.get("limited"): c = 429 # a 429 IS a refusal; its form can be judged if c is None: missing.append(k); continue body = (v.get("body") or "").lower() if 200 <= c < 300 and any(mk in body for mk in CHALLENGE_MARKS): bad.append(f"{k}: {c} with challenge marker {[mk for mk in CHALLENGE_MARKS if mk in body][0]!r}") if c >= 400 and "text/html" in (v.get("ctype") or ""): bad.append(f"{k}: {c} refused with Content-Type text/html to Accept: application/json") if bad: return "FAIL", "; ".join(bad) if missing: return "UNOBSERVED", "no answer for: " + ", ".join(missing) return "PASS", "" def l0_4(t, declared_codes): other = "GET" if t["method"].upper() != "GET" else "POST" probe = dict(t); probe.setdefault("body", "{}") v = limited(via_urllib(t["url"], other, probe["body"] if other == "POST" else None, {"Content-Type": "application/json"} if other == "POST" else {})) c = v.get("status") if c is None: return "UNOBSERVED", other, None, v.get("error") declared = {x for x in declared_codes.values() if x is not None} if c == 405: allow = (v.get("allow") or "").upper() if t["method"].upper() in [a.strip() for a in allow.split(",")]: return "PASS", other, c, f"405 + Allow: {v.get('allow')}" return "FAIL", other, c, f"405 without the declared method in Allow (Allow: {v.get('allow')!r})" if not declared: return "UNOBSERVED", other, c, "the declared method's own answer was not observed, so there is nothing to compare with" if c in declared: return "PASS", other, c, "same answer as the declared method" return "FAIL", other, c, f"{other} answered {c}; declared {t['method']} answers {sorted(declared)}" def main(): if len(sys.argv) < 2: print(__doc__); sys.exit(2) spec = json.load(open(sys.argv[1])); out_path = sys.argv[sys.argv.index("--json") + 1] if "--json" in sys.argv else None commerce = spec.get("commerce", []); docs = list(spec.get("documents", [])) for t in commerce: t["method"] = t.get("method", "GET").upper() hosts = sorted({urlsplit(t["url"]).hostname for t in commerce + docs}) for origin in sorted({"https://" + urlsplit(t["url"]).netloc for t in commerce}): if not any(d["url"] == origin + "/robots.txt" for d in docs): docs.append({"url": origin + "/robots.txt", "source": "added by the tester (L0-5)", "robots": True}) rep = {"spec": "readiness-l0 draft 0.1", "started": now(), "profiles": [p[0] for p in PROFILES], "results": []} add = lambda req, url, result, detail, **kw: rep["results"].append({"req": req, "url": url, "result": result, "detail": detail, "t": now(), **kw}) for h in hosts: r = l0_1(h); add("L0-1", h, r["result"], r["detail"]) for t in commerce: a = ask(t); res, codes, why = parity(a); add("L0-2", t["url"], res, why, method=t["method"], codes=codes, source=t.get("source")) a3 = ask(t, accept="application/json"); res3, why3 = l0_3(a3); add("L0-3", t["url"], res3, why3, method=t["method"], codes={k: v.get("status") for k, v in a3.items()}) res4, other, c4, why4 = l0_4(t, codes); add("L0-4", t["url"], res4, why4, sent=other, status=c4) robots = {} for d in docs: dt = {"url": d["url"], "method": "GET"}; a = ask(dt) is_robots = d["url"].endswith("/robots.txt") codes = {k: v.get("status") for k, v in a.items()} if is_robots and set(codes.values()) == {404}: add("L0-5", d["url"], "PASS", "no robots.txt (404 to every profile): nothing is disallowed", codes=codes, source=d.get("source")) robots[urlsplit(d["url"]).netloc] = None; continue res, codes, why = parity(a, want_2xx=True); add("L0-5", d["url"], res, why, codes=codes, source=d.get("source")) if is_robots and a["python-urllib"].get("status") == 200: robots[urlsplit(d["url"]).netloc] = a["python-urllib"]["body"] for t in commerce: net = urlsplit(t["url"]).netloc if net not in robots: add("L0-5", t["url"], "UNOBSERVED", "robots.txt for this host was not read", check="robots"); continue if robots[net] is None: continue rp = urllib.robotparser.RobotFileParser(); rp.parse(robots[net].splitlines()) ok = rp.can_fetch("*", t["url"]); add("L0-5", t["url"], "PASS" if ok else "FAIL", "" if ok else "robots.txt disallows this commerce URL for User-agent: *", check="robots") rep["finished"] = now() words = [r["result"] for r in rep["results"]] rep["layer"] = "FAIL" if "FAIL" in words else ("INCOMPLETE" if "UNOBSERVED" in words else "PASS") rep["counts"] = {w: words.count(w) for w in ("PASS", "FAIL", "UNOBSERVED")} for r in rep["results"]: extra = " ".join(f"{k}={r[k]}" for k in ("method", "sent", "status", "codes", "check") if k in r and r[k] is not None) print(f"{r['result']:<10} {r['req']} {r['url']} {extra} {r['detail']}".rstrip()) print(f"\nLayer 0: {rep['layer']} {rep['counts']} profiles: {', '.join(rep['profiles'])} {rep['started']} → {rep['finished']}") print("Does not see: other vantage points and IP reputation; rate limits after the first request; body differences when the status matches.") if out_path: json.dump(rep, open(out_path, "w"), indent=1) sys.exit(1 if rep["layer"] == "FAIL" else 0) if __name__ == "__main__": main()