From e58b1edd39a80b3cf66e536d4956bcd8a5871f15 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:32:10 +0000 Subject: [PATCH 1/5] State the network surface commitment and assert the page matches it Readers copy the destination table and the CSP snippets on this page straight into firewalls and Content Security Policies, but nothing said Kernel would keep those destinations stable and nothing checked that the table, the snippets and the platform agreed. The table and its own CSP block have already drifted apart once. Add a "What we commit to" section stating that the destinations and ports do not move without a changelog entry and advance notice, along with the three things inside this page's scope that are deliberately not covered. Vendor compat/network_surface.json from kernel/kernel packages/api/lib/compat, where the API validates session URLs against the same file and the Ansible inventory validator checks host domains against it. scripts/check_network_surface.py asserts the page's table, Live View CSP block and stated exclusions all match it, and runs on every pull request. Co-Authored-By: Claude Opus 5 --- .github/workflows/network-surface.yml | 19 +++++ compat/network_surface.json | 47 +++++++++++ info/network-access.mdx | 10 +++ scripts/check_network_surface.py | 107 ++++++++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 .github/workflows/network-surface.yml create mode 100644 compat/network_surface.json create mode 100755 scripts/check_network_surface.py diff --git a/.github/workflows/network-surface.yml b/.github/workflows/network-surface.yml new file mode 100644 index 00000000..ba5ee2e0 --- /dev/null +++ b/.github/workflows/network-surface.yml @@ -0,0 +1,19 @@ +name: Check Network Surface + +on: + pull_request: + push: + branches: [main] + +jobs: + network-surface: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check the allowlist page matches the published surface + run: python3 scripts/check_network_surface.py diff --git a/compat/network_surface.json b/compat/network_surface.json new file mode 100644 index 00000000..136193d0 --- /dev/null +++ b/compat/network_surface.json @@ -0,0 +1,47 @@ +{ + "$source": "kernel/kernel packages/api/lib/compat/network_surface.json. Copies in kernel/infra and kernel/docs are vendored and must stay byte-identical.", + "destinations": [ + { + "feature": "API, SDKs, and authenticated CLI requests", + "hosts": ["api.onkernel.com"], + "port": 443, + "protocol": "HTTPS" + }, + { + "feature": "CLI authentication (`kernel login`)", + "hosts": ["auth.onkernel.com", "dashboard.onkernel.com"], + "port": 443, + "protocol": "HTTPS" + }, + { + "feature": "CDP, WebDriver BiDi, Live View, replays, and app connections", + "hosts": ["*.onkernel.com", "*.kernel.sh"], + "port": 8443, + "protocol": "HTTPS and secure WebSocket (WSS)", + "session_facing": true + }, + { + "feature": "Live View media negotiation", + "hosts": ["stun.relay.metered.ca"], + "port": 80, + "protocol": "STUN over UDP" + }, + { + "feature": "Live View media relay", + "hosts": ["global.relay.metered.ca"], + "port": 443, + "protocol": "TURN over UDP or TLS/TCP" + }, + { + "feature": "Replay files", + "hosts": ["kernel-api-prod.s3.us-east-1.amazonaws.com"], + "port": 443, + "protocol": "HTTPS" + } + ], + "not_covered": [ + "The specific hostname inside a wildcard, as returned in `cdp_ws_url`, `webdriver_ws_url` or `browser_live_view_url`. It changes between sessions.", + "Which metro or host serves a given session.", + "Egress IP addresses, including residential, ISP and datacenter proxy exit IPs." + ] +} diff --git a/info/network-access.mdx b/info/network-access.mdx index 64fe89cf..3f99186c 100644 --- a/info/network-access.mdx +++ b/info/network-access.mdx @@ -7,6 +7,16 @@ Add these destinations to your allowlist when a firewall, proxy, or sandbox rest Allow only the destinations used by your integration. Apply the Live View and replay rules to each viewer's network, not just the machine running your agent. +## What we commit to + +The destinations and ports on this page do not change without a [changelog](/changelog) entry and advance notice. If Kernel needs to serve traffic from somewhere new, it appears here before it reaches your account. + +Within the scope of this page, the following are deliberately not covered and can change at any time: + +- The specific hostname inside a wildcard, as returned in `cdp_ws_url`, `webdriver_ws_url` or `browser_live_view_url`. It changes between sessions. +- Which metro or host serves a given session. +- Egress IP addresses, including residential, ISP and datacenter proxy exit IPs. + ## Required destinations | Feature | Destinations | Port | Protocol | diff --git a/scripts/check_network_surface.py b/scripts/check_network_surface.py new file mode 100755 index 00000000..62f477f5 --- /dev/null +++ b/scripts/check_network_surface.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Assert info/network-access.mdx matches the published network surface. + +Customers copy the destination table and the CSP snippets on that page straight +into firewalls and Content Security Policies. The page and the surface have +already drifted from each other once, so this keeps them the same object: +compat/network-surface.json is vendored from kernel/kernel +packages/api/lib/compat/network_surface.json, which the API and the Ansible +inventory validator also read. +""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SURFACE = ROOT / "compat/network_surface.json" +PAGE = ROOT / "info/network-access.mdx" + +TABLE_ROW = re.compile(r"^\|(?!\s*(?:Feature|-))(.+)\|\s*$", re.M) +CODE_FENCE = re.compile(r"```text\n(.*?)```", re.S) +ORIGIN = re.compile(r"(?:https|wss)://(\S+?):(\d+)") + + +def backticked(cell): + return [v.strip() for v in re.findall(r"`([^`]+)`", cell)] + + +def table_rows(page): + """(feature, hosts, port, protocol) for each row of the destinations table.""" + section = page.split("## Required destinations", 1)[-1].split("\n## ", 1)[0] + rows = [] + for raw in TABLE_ROW.findall(section): + cells = [c.strip() for c in raw.split("|")] + if len(cells) != 4: + continue + feature, hosts, port, protocol = cells + rows.append((feature, backticked(hosts), backticked(port), protocol)) + return rows + + +def check_table(surface, page): + errors = [] + documented = table_rows(page) + published = surface["destinations"] + if len(documented) != len(published): + return [f"table has {len(documented)} rows, surface has {len(published)} destinations"] + for row, dest in zip(documented, published): + feature, hosts, ports, protocol = row + if feature != dest["feature"]: + errors.append(f"feature {feature!r} != {dest['feature']!r}") + if hosts != dest["hosts"]: + errors.append(f"{dest['feature']}: hosts {hosts} != {dest['hosts']}") + if ports != [str(dest["port"])]: + errors.append(f"{dest['feature']}: port {ports} != [{dest['port']!r}]") + if protocol != dest["protocol"]: + errors.append(f"{dest['feature']}: protocol {protocol!r} != {dest['protocol']!r}") + return errors + + +def check_csp(surface, page): + """Every session-facing host:port must appear in the Live View CSP, and nothing else.""" + session_facing = { + f"{host}:{dest['port']}" + for dest in surface["destinations"] + if dest.get("session_facing") + for host in dest["hosts"] + } + blocks = CODE_FENCE.findall(page) + if not blocks: + return ["no ```text CSP block found"] + live_view = {f"{h}:{p}" for h, p in ORIGIN.findall(blocks[0])} + errors = [] + for missing in sorted(session_facing - live_view): + errors.append(f"Live View CSP is missing {missing}") + for extra in sorted(o for o in live_view - session_facing if o.startswith("*.")): + errors.append(f"Live View CSP allows {extra}, which is not session-facing in the surface") + return errors + + +def check_exclusions(surface, page): + """The stated scope of the commitment has to travel with the commitment.""" + return [f"page is missing the stated exclusion: {line}" + for line in surface["not_covered"] if line not in page] + + +def main(): + surface = json.loads(SURFACE.read_text()) + page = PAGE.read_text() + errors = check_table(surface, page) + check_csp(surface, page) + check_exclusions(surface, page) + if errors: + print(f"{PAGE.relative_to(ROOT)} does not match {SURFACE.relative_to(ROOT)}:\n", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + print( + "\nThe surface is a published commitment. Update it in kernel/kernel first, " + "sync the copies, and land the changelog entry in the same change.", + file=sys.stderr, + ) + return 1 + print(f"network surface matches: {len(surface['destinations'])} destinations, CSP and exclusions consistent") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 84fbe761cc8f92bfbc6eb1c28af20c303d7b1118 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:42:47 +0000 Subject: [PATCH 2/5] Vendor the current network surface and pin it by digest --- compat/network_surface.json | 2 +- compat/network_surface.sha256 | 1 + scripts/check_network_surface.py | 33 +++++++++++++++++++++++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 compat/network_surface.sha256 diff --git a/compat/network_surface.json b/compat/network_surface.json index 136193d0..42d606e7 100644 --- a/compat/network_surface.json +++ b/compat/network_surface.json @@ -1,5 +1,5 @@ { - "$source": "kernel/kernel packages/api/lib/compat/network_surface.json. Copies in kernel/infra and kernel/docs are vendored and must stay byte-identical.", + "version": 1, "destinations": [ { "feature": "API, SDKs, and authenticated CLI requests", diff --git a/compat/network_surface.sha256 b/compat/network_surface.sha256 new file mode 100644 index 00000000..655a7bab --- /dev/null +++ b/compat/network_surface.sha256 @@ -0,0 +1 @@ +623fc5251f1b6fa4fed5e703f252fed748a64fa7b1ddb5c1befae67387c98a52 diff --git a/scripts/check_network_surface.py b/scripts/check_network_surface.py index 62f477f5..86d675bb 100755 --- a/scripts/check_network_surface.py +++ b/scripts/check_network_surface.py @@ -4,11 +4,16 @@ Customers copy the destination table and the CSP snippets on that page straight into firewalls and Content Security Policies. The page and the surface have already drifted from each other once, so this keeps them the same object: -compat/network-surface.json is vendored from kernel/kernel -packages/api/lib/compat/network_surface.json, which the API and the Ansible +compat/network_surface.json is vendored byte-identical from +kernel/kernel contracts/network-surface.json, which the API and the Ansible inventory validator also read. + +The vendored file is paired with compat/network_surface.sha256, the digest of +the canonical manifest it was vendored from. That is the pin: it records which +version of the surface this repo is on, and catches a copy edited in place. """ +import hashlib import json import re import sys @@ -16,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[1] SURFACE = ROOT / "compat/network_surface.json" +PIN = ROOT / "compat/network_surface.sha256" PAGE = ROOT / "info/network-access.mdx" TABLE_ROW = re.compile(r"^\|(?!\s*(?:Feature|-))(.+)\|\s*$", re.M) @@ -85,10 +91,31 @@ def check_exclusions(surface, page): for line in surface["not_covered"] if line not in page] +def check_pin(): + """The vendored copy has to be the one the pinned digest names. + + The sidecar is written by the sync workflow in kernel/kernel, never by hand, + so a mismatch means either the copy or the pin was edited in place. + """ + pinned = PIN.read_text().split()[0] + vendored = hashlib.sha256(SURFACE.read_bytes()).hexdigest() + if pinned != vendored: + return [ + f"{SURFACE.relative_to(ROOT)} does not match {PIN.relative_to(ROOT)} " + f"(pinned {pinned}, vendored {vendored})" + ] + return [] + + def main(): surface = json.loads(SURFACE.read_text()) page = PAGE.read_text() - errors = check_table(surface, page) + check_csp(surface, page) + check_exclusions(surface, page) + errors = ( + check_table(surface, page) + + check_csp(surface, page) + + check_exclusions(surface, page) + + check_pin() + ) if errors: print(f"{PAGE.relative_to(ROOT)} does not match {SURFACE.relative_to(ROOT)}:\n", file=sys.stderr) for error in errors: From 72af411612cfb0da0bb54364c61ad4da65295bd9 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:43:01 +0000 Subject: [PATCH 3/5] Vendor the port 443 surface and compare ports as a list The canonical manifest lists ports ascending and records the port 443 transition, so the vendored copy and its pin move with it. The table check compares the port cell to the list, and a CSP source with no port counts as 443, so the origins the rollout added have to be present. --- compat/network_surface.json | 21 +++++++++++++-------- compat/network_surface.sha256 | 2 +- scripts/check_network_surface.py | 28 +++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/compat/network_surface.json b/compat/network_surface.json index 42d606e7..1381112b 100644 --- a/compat/network_surface.json +++ b/compat/network_surface.json @@ -1,41 +1,46 @@ { - "version": 1, + "version": 2, "destinations": [ { "feature": "API, SDKs, and authenticated CLI requests", "hosts": ["api.onkernel.com"], - "port": 443, + "ports": [443], "protocol": "HTTPS" }, { "feature": "CLI authentication (`kernel login`)", "hosts": ["auth.onkernel.com", "dashboard.onkernel.com"], - "port": 443, + "ports": [443], "protocol": "HTTPS" }, { "feature": "CDP, WebDriver BiDi, Live View, replays, and app connections", "hosts": ["*.onkernel.com", "*.kernel.sh"], - "port": 8443, + "ports": [443, 8443], "protocol": "HTTPS and secure WebSocket (WSS)", - "session_facing": true + "session_facing": true, + "transition": { + "adding": [443], + "retiring": [8443], + "retire_by": null + } }, { "feature": "Live View media negotiation", "hosts": ["stun.relay.metered.ca"], - "port": 80, + "ports": [80], "protocol": "STUN over UDP" }, { "feature": "Live View media relay", "hosts": ["global.relay.metered.ca"], - "port": 443, + "ports": [443], "protocol": "TURN over UDP or TLS/TCP" }, { "feature": "Replay files", "hosts": ["kernel-api-prod.s3.us-east-1.amazonaws.com"], - "port": 443, + "ports": [443], "protocol": "HTTPS" } ], diff --git a/compat/network_surface.sha256 b/compat/network_surface.sha256 index 655a7bab..00a97e77 100644 --- a/compat/network_surface.sha256 +++ b/compat/network_surface.sha256 @@ -1 +1 @@ -623fc5251f1b6fa4fed5e703f252fed748a64fa7b1ddb5c1befae67387c98a52 +4d2582e34e3444f8d257aad46e22fbaf6d7afd1c5624947496ce82cb65289395 diff --git a/scripts/check_network_surface.py b/scripts/check_network_surface.py index 86d675bb..edfcf676 100755 --- a/scripts/check_network_surface.py +++ b/scripts/check_network_surface.py @@ -26,7 +26,23 @@ TABLE_ROW = re.compile(r"^\|(?!\s*(?:Feature|-))(.+)\|\s*$", re.M) CODE_FENCE = re.compile(r"```text\n(.*?)```", re.S) -ORIGIN = re.compile(r"(?:https|wss)://(\S+?):(\d+)") +SOURCE = re.compile(r"^(?:https|wss)://(.+)$") + + +def csp_origins(block): + """host:port for every source in a CSP block. + + A source may leave the port off, and both schemes the page uses default to + 443, so the page is compared as hosts and ports rather than as text. + """ + origins = set() + for source in block.split(): + match = SOURCE.match(source.rstrip(";")) + if match is None: + continue + host, _, port = match.group(1).partition(":") + origins.add(f"{host}:{port or '443'}") + return origins def backticked(cell): @@ -58,8 +74,9 @@ def check_table(surface, page): errors.append(f"feature {feature!r} != {dest['feature']!r}") if hosts != dest["hosts"]: errors.append(f"{dest['feature']}: hosts {hosts} != {dest['hosts']}") - if ports != [str(dest["port"])]: - errors.append(f"{dest['feature']}: port {ports} != [{dest['port']!r}]") + published_ports = [str(port) for port in dest["ports"]] + if ports != published_ports: + errors.append(f"{dest['feature']}: ports {ports} != {published_ports}") if protocol != dest["protocol"]: errors.append(f"{dest['feature']}: protocol {protocol!r} != {dest['protocol']!r}") return errors @@ -68,15 +85,16 @@ def check_table(surface, page): def check_csp(surface, page): """Every session-facing host:port must appear in the Live View CSP, and nothing else.""" session_facing = { - f"{host}:{dest['port']}" + f"{host}:{port}" for dest in surface["destinations"] if dest.get("session_facing") for host in dest["hosts"] + for port in dest["ports"] } blocks = CODE_FENCE.findall(page) if not blocks: return ["no ```text CSP block found"] - live_view = {f"{h}:{p}" for h, p in ORIGIN.findall(blocks[0])} + live_view = csp_origins(blocks[0]) errors = [] for missing in sorted(session_facing - live_view): errors.append(f"Live View CSP is missing {missing}") From 165c827ff29c280fb029145b6295cc2d844ae5ee Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:56:05 +0000 Subject: [PATCH 4/5] Name the port cell after what it holds --- scripts/check_network_surface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/check_network_surface.py b/scripts/check_network_surface.py index edfcf676..7d987c59 100755 --- a/scripts/check_network_surface.py +++ b/scripts/check_network_surface.py @@ -50,15 +50,15 @@ def backticked(cell): def table_rows(page): - """(feature, hosts, port, protocol) for each row of the destinations table.""" + """(feature, hosts, ports, protocol) for each row of the destinations table.""" section = page.split("## Required destinations", 1)[-1].split("\n## ", 1)[0] rows = [] for raw in TABLE_ROW.findall(section): cells = [c.strip() for c in raw.split("|")] if len(cells) != 4: continue - feature, hosts, port, protocol = cells - rows.append((feature, backticked(hosts), backticked(port), protocol)) + feature, hosts, ports, protocol = cells + rows.append((feature, backticked(hosts), backticked(ports), protocol)) return rows From 7ae010bd66f20c7ed43d72e660d020b8f26a54a8 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:51:15 +0000 Subject: [PATCH 5/5] Vendor network surface 968867fe4719 Matches kernel/kernel contracts/network-surface.json, which is now in canonical JSON.stringify form and no longer carries `version` or `transition`. Nothing here read either field. Co-Authored-By: Claude Opus 5.5 --- compat/network_surface.json | 59 ++++++++++++++++++++++++----------- compat/network_surface.sha256 | 2 +- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/compat/network_surface.json b/compat/network_surface.json index 1381112b..25c4a7bc 100644 --- a/compat/network_surface.json +++ b/compat/network_surface.json @@ -1,46 +1,67 @@ { - "version": 2, "destinations": [ { "feature": "API, SDKs, and authenticated CLI requests", - "hosts": ["api.onkernel.com"], - "ports": [443], + "hosts": [ + "api.onkernel.com" + ], + "ports": [ + 443 + ], "protocol": "HTTPS" }, { "feature": "CLI authentication (`kernel login`)", - "hosts": ["auth.onkernel.com", "dashboard.onkernel.com"], - "ports": [443], + "hosts": [ + "auth.onkernel.com", + "dashboard.onkernel.com" + ], + "ports": [ + 443 + ], "protocol": "HTTPS" }, { "feature": "CDP, WebDriver BiDi, Live View, replays, and app connections", - "hosts": ["*.onkernel.com", "*.kernel.sh"], - "ports": [443, 8443], + "hosts": [ + "*.onkernel.com", + "*.kernel.sh" + ], + "ports": [ + 443, + 8443 + ], "protocol": "HTTPS and secure WebSocket (WSS)", - "session_facing": true, - "transition": { - "adding": [443], - "retiring": [8443], - "retire_by": null - } + "session_facing": true }, { "feature": "Live View media negotiation", - "hosts": ["stun.relay.metered.ca"], - "ports": [80], + "hosts": [ + "stun.relay.metered.ca" + ], + "ports": [ + 80 + ], "protocol": "STUN over UDP" }, { "feature": "Live View media relay", - "hosts": ["global.relay.metered.ca"], - "ports": [443], + "hosts": [ + "global.relay.metered.ca" + ], + "ports": [ + 443 + ], "protocol": "TURN over UDP or TLS/TCP" }, { "feature": "Replay files", - "hosts": ["kernel-api-prod.s3.us-east-1.amazonaws.com"], - "ports": [443], + "hosts": [ + "kernel-api-prod.s3.us-east-1.amazonaws.com" + ], + "ports": [ + 443 + ], "protocol": "HTTPS" } ], diff --git a/compat/network_surface.sha256 b/compat/network_surface.sha256 index 00a97e77..96083576 100644 --- a/compat/network_surface.sha256 +++ b/compat/network_surface.sha256 @@ -1 +1 @@ -4d2582e34e3444f8d257aad46e22fbaf6d7afd1c5624947496ce82cb65289395 +968867fe4719035fc740259af9634babd648c816cf02981d59b31d882028a0f5