glovebox-hosts 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ .worktrees
2
+ .claude/worktrees/
3
+ .agents/
4
+ .codex/
5
+
6
+ # vm agent-entrypoint boot trace — a runtime log appended into the workspace by
7
+ # gb_boot_trace; a diagnostic artifact, never committed.
8
+ .gb-agent-boot-trace
9
+
10
+ # Dependencies
11
+ # Tool-managed dirs use the BARE name (no trailing slash). A `foo/` pattern
12
+ # matches only a *directory*, so a symlink named `foo` — e.g. one linked into a
13
+ # git worktree to run tests — slips past it and `git add -A` stages it (how a
14
+ # node_modules symlink twice reached CI this repo's history). The bare name
15
+ # matches the dir AND a same-named symlink/file, so tool output can't be
16
+ # committed by accident. Only applied to names that are never a legitimately
17
+ # tracked file (dependency/venv/cache/report dirs); generic names like dist/ or
18
+ # build/ keep the trailing slash so they don't over-ignore a real file.
19
+ node_modules
20
+ .pnpm-store
21
+
22
+ # Build outputs
23
+ dist/
24
+ build/
25
+ out/
26
+
27
+ # Environment files
28
+ .env
29
+ .env.local
30
+ .env.*.local
31
+
32
+ # IDE
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+ *.swo
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # Logs
43
+ *.log
44
+ npm-debug.log*
45
+ pnpm-debug.log*
46
+
47
+ # Coverage
48
+ coverage/
49
+ coverage.json
50
+ .c8-output
51
+ .coverage
52
+ .coverage.*
53
+ .py-coverage.lock
54
+ htmlcov
55
+
56
+ # Mutation testing (Stryker): sandbox, HTML report, and the incremental cache
57
+ .stryker-tmp
58
+ reports/
59
+
60
+ # Generated LaunchAgent (rendered from the .template by setup.bash at install time)
61
+ launchagents/*.generated.plist
62
+
63
+ # Python
64
+ __pycache__
65
+ *.pyc
66
+ .venv
67
+ .uv
68
+ .pytest_cache
69
+
70
+ # `uv run` inside inspect-glovebox/, glovebox-monitor/ or glovebox-driver/ writes a lock
71
+ # beside that pyproject.toml. Each wheel is a library, so it pins nothing; committing one
72
+ # adds a `uv.lock -merge` path that scripts/lib-regen-rules.mjs owns no rule for, which
73
+ # reds the JS suite.
74
+ /inspect-glovebox/uv.lock
75
+ /glovebox-monitor/uv.lock
76
+ /glovebox-driver/uv.lock
77
+ # pytest scratch (repo-relative --basetemp): throwaway fixture files, including
78
+ # fake executables the suite writes (keyprobe, docker stubs). Ignored so they never
79
+ # surface as uncommitted changes or get captured onto a session-end review branch.
80
+ .pytest-tmp
81
+
82
+ # Fuzzer crash reproducers (jazzer.js / atheris drop these in the repo root, which
83
+ # is their cwd). Anchored with a leading slash: an unanchored `crash-*` matches at
84
+ # ANY depth, so it silently swallowed bin/checks/vm/crash-resilience-int.bash — a
85
+ # `git add` of a legitimately-named source file that reports success and stages
86
+ # nothing.
87
+ /crash-*
88
+ /timeout-*
89
+ /oom-*
90
+ /leak-*
91
+
92
+ # History working files — each lives on the perf-history or ci-timings branch and is
93
+ # seeded into the checkout by persist-perf-history.sh read or record-setup-timing.sh;
94
+ # never committed to main or a feature branch. A glob, not a list: a tool that walks
95
+ # "tracked plus untracked-not-ignored" takes a seeded file for a repository file, and
96
+ # a per-metric list leaves the next metric's file out of the pattern.
97
+ .github/*-history.json
98
+ .github/*-history.jsonl
99
+
100
+ # pytest --basetemp droppings (used where /tmp is noexec)
101
+ .pytest-exec-tmp
102
+
103
+ # Hypothesis property-testing example database (regenerated locally; never committed)
104
+ .hypothesis/
105
+
106
+ # CI timing maps that balance the shard fan-outs. They live in R2 (uploaded only by
107
+ # main runs, fetched best-effort by CI — see .github/ci-durations.json) and are never
108
+ # committed; ignored so a CI fetch or a local `uv run pytest --store-durations` /
109
+ # vm-live run can't accidentally stage them.
110
+ tests/.gb-test-durations.json
111
+ tests/.gb-test-uninstrumented-durations.json
112
+ tests/.gb-kcov-durations.json
113
+ tests/.gb-drvfs-durations.json
114
+ tests/.gb-macos-durations.json
115
+ # The plan job's published shard assignment, one per leg (keyed by that leg's
116
+ # duration-map name): derived from the fetched map, shipped to the shards in the
117
+ # same artifact, never committed.
118
+ tests/.gb-*-shard-assignment.json
119
+ tests/.gb-select-maps.json
120
+ .github/vm-live/durations.json
121
+ .github/kata-live/durations.json
122
+
123
+ # The pinned scanner binary that .github/scripts/python-deps-vuln-scan.sh downloads
124
+ # into the repo root (~56 MB). Running that script locally to reproduce a red
125
+ # osv-scanner check is the documented response to one, so the artifact lands in
126
+ # every contributor's tree; ignored so it can't be staged by a `git add -A`.
127
+ /osv-scanner
128
+
129
+ # esbuild output — every artifact scripts/build-hook-bundles.mjs or
130
+ # scripts/build-vm-dispatcher.mjs writes, wherever its home. Built once per tree by
131
+ # `pnpm install`'s postinstall, by setup.bash at install time, in the nix buildPhase and
132
+ # in the image's own bundler stage, so the reviewable surface is the sources plus
133
+ # pnpm-lock.yaml. Suffix, not a directory or a member list: a bundle whose home the
134
+ # pattern misses leaves every tree that builds it permanently dirty.
135
+ *.bundle.mjs
136
+
137
+ # Sharded mutation configs are deterministic build output. The planner and
138
+ # `pnpm install` render them from config/mutation-sessions.json.
139
+ /tools/mutation/*of*.toml
140
+
141
+ # The pinned kcov binary that tests/install-kcov-local.sh builds into the repo
142
+ # root — the same path .github/actions/install-kcov caches to, so tests/run-kcov.sh
143
+ # finds a local build with no PATH edit. Reproducing a red "Bash coverage (kcov)"
144
+ # check locally is the documented response to one, so the artifact lands in every
145
+ # contributor's tree; ignored so it can't be staged by a `git add -A`.
146
+ /kcov-bin/
147
+ .claude/.hookpin-*/
148
+
149
+ # synced-deps.test.mjs's untracked-file probe, mkdtemp'd under a synced path on
150
+ # purpose. Ignored so a concurrent `git add -A` elsewhere in this checkout can't
151
+ # stage it mid-test: staged, it would pass trackedUnder()'s check and leave an
152
+ # AD entry in the shared index once the test deletes it from disk.
153
+ .claude/consumer-probe-*/
154
+
155
+ # `ct settings pull` writes this per-checkout pin cache into the repo root. The pin this
156
+ # harness measures lives in evals/control_tower/ct-settings-pin.txt; a committed copy here
157
+ # would be a second one that drifts.
158
+ .settings.local.yml
159
+
160
+ # `gh` writes its per-machine state here when it runs with the repo root as the
161
+ # XDG state home — a device identifier, which no checkout should carry.
162
+ /.local/
163
+
164
+ # Generated doc pages, published to the CDN instead of committed
165
+ # (scripts/render-doc-site.py, .github/workflows/docs-publish.yaml). A committed
166
+ # page is re-derived by every branch that touches its generator's inputs, which
167
+ # made these the busiest files in the history and conflicted branches on bytes
168
+ # nobody typed. Their generators still run, and still refuse a bad input; only
169
+ # the output stays out of the tree.
170
+ /docs/architecture-callgraph.md
171
+ /docs/ci-map.generated.md
172
+ /docs/inspect-evals-coverage.generated.md
173
+ /docs/tla/configs.md
174
+ /docs/tla/reachable-subsets.md
175
+ /docs/tla/diagrams/
176
+ /docs/tla/modules/
177
+ # TLC's own scratch metadir, written here by a run given no -metadir.
178
+ /docs/tla/states/
179
+
180
+ # The eval time series live on the `metrics-history` branch, which `main` never
181
+ # merges (.github/scripts/eval-series.sh). A tracker or a local run materializes its
182
+ # own working copy here; committing one would put the series back on the merge path.
183
+ metrics/monitor-eval.jsonl
184
+ metrics/sabotage-eval.jsonl
185
+ metrics/breakout-ctf.jsonl
186
+
187
+ # `nix build` links its output here.
188
+ /result
189
+ /result-*
190
+
191
+ # go build output of the guest network daemon
192
+ bin/lib/kata/netd/netd
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.5
2
+ Name: glovebox-hosts
3
+ Version: 0.4.0
4
+ Summary: The one hostname spelling and host-matching rules every glovebox component applies
5
+ Project-URL: Homepage, https://github.com/AlexanderMattTurner/agent-glovebox
6
+ Project-URL: Source, https://github.com/AlexanderMattTurner/agent-glovebox
7
+ Author: AlexanderMattTurner
8
+ License-Expression: Apache-2.0
9
+ Keywords: egress,glovebox,hostname,sandbox
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: tldextract==5.3.2
16
+ Description-Content-Type: text/markdown
17
+
18
+ # glovebox-hosts
19
+
20
+ The one answer to "which host is this" that every part of glovebox uses: the egress proxy's verdict service, the launcher, the WebFetch hook, the session driver and the evals.
21
+
22
+ A host is compared in one spelling: ASCII only, surrounding whitespace removed, lowercased, and an IP literal in its standard form. A name that is not ASCII, or that starts or ends with a dot, is refused with `HostError` rather than folded, so two readers can never disagree about it.
23
+
24
+ ```python
25
+ >>> from glovebox_hosts import canonical_host, split_authority, granting_entries
26
+ >>> split_authority("[2001:DB8::1]:443")
27
+ ('2001:db8::1', 443)
28
+ >>> granting_entries("API.example.com", ["example.com"])
29
+ ['example.com']
30
+ >>> granting_entries("a.b.example.com", ["example.com"])
31
+ []
32
+ ```
33
+
34
+ A grant reaches the entry and one label below it, the depth one certificate wildcard covers. A bar (`constraining_entries`) reaches any depth. A grant may not name a `*.` wildcard over a public suffix, or over a name with one below it (`suffix_below`): `grant_name_refusal` says why, reading the Public Suffix List that `tldextract` bundles. A grant on a public suffix itself is allowed, and `grant_is_exact` tells the policy render to hold it to that one host. That is the package's one dependency, and it is imported only when a grant name is judged.
35
+
36
+ `pod_grants` reads a Kata pod's `glovebox.dev/egress-grants` annotation as every node reads it, so an admission webhook can refuse a pod at submission for the same reason the node would refuse it later. It returns the granted hosts at their tiers and the address grants, and raises `GrantRefused` with the node's own message for the first entry the node refuses. How wide an address grant may be is each node's own setting, so it is not checked here.
37
+
38
+ ```python
39
+ >>> from glovebox_hosts import GrantRefused, pod_grants
40
+ >>> pod_grants('["httpbin.org", {"host": "203.0.113.5:8443", "rw": true}]').hosts
41
+ {'httpbin.org': 'ro'}
42
+ >>> pod_grants('["*.amazonaws.com"]')
43
+ Traceback (most recent call last):
44
+ ...
45
+ glovebox_hosts.GrantRefused: glovebox.dev/egress-grants: '*.amazonaws.com' is a wildcard over a name with the public suffix ...
46
+ ```
@@ -0,0 +1,29 @@
1
+ # glovebox-hosts
2
+
3
+ The one answer to "which host is this" that every part of glovebox uses: the egress proxy's verdict service, the launcher, the WebFetch hook, the session driver and the evals.
4
+
5
+ A host is compared in one spelling: ASCII only, surrounding whitespace removed, lowercased, and an IP literal in its standard form. A name that is not ASCII, or that starts or ends with a dot, is refused with `HostError` rather than folded, so two readers can never disagree about it.
6
+
7
+ ```python
8
+ >>> from glovebox_hosts import canonical_host, split_authority, granting_entries
9
+ >>> split_authority("[2001:DB8::1]:443")
10
+ ('2001:db8::1', 443)
11
+ >>> granting_entries("API.example.com", ["example.com"])
12
+ ['example.com']
13
+ >>> granting_entries("a.b.example.com", ["example.com"])
14
+ []
15
+ ```
16
+
17
+ A grant reaches the entry and one label below it, the depth one certificate wildcard covers. A bar (`constraining_entries`) reaches any depth. A grant may not name a `*.` wildcard over a public suffix, or over a name with one below it (`suffix_below`): `grant_name_refusal` says why, reading the Public Suffix List that `tldextract` bundles. A grant on a public suffix itself is allowed, and `grant_is_exact` tells the policy render to hold it to that one host. That is the package's one dependency, and it is imported only when a grant name is judged.
18
+
19
+ `pod_grants` reads a Kata pod's `glovebox.dev/egress-grants` annotation as every node reads it, so an admission webhook can refuse a pod at submission for the same reason the node would refuse it later. It returns the granted hosts at their tiers and the address grants, and raises `GrantRefused` with the node's own message for the first entry the node refuses. How wide an address grant may be is each node's own setting, so it is not checked here.
20
+
21
+ ```python
22
+ >>> from glovebox_hosts import GrantRefused, pod_grants
23
+ >>> pod_grants('["httpbin.org", {"host": "203.0.113.5:8443", "rw": true}]').hosts
24
+ {'httpbin.org': 'ro'}
25
+ >>> pod_grants('["*.amazonaws.com"]')
26
+ Traceback (most recent call last):
27
+ ...
28
+ glovebox_hosts.GrantRefused: glovebox.dev/egress-grants: '*.amazonaws.com' is a wildcard over a name with the public suffix ...
29
+ ```
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "glovebox-hosts"
3
+ version = "0.4.0"
4
+ description = "The one hostname spelling and host-matching rules every glovebox component applies"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "AlexanderMattTurner" }]
9
+ keywords = ["glovebox", "sandbox", "egress", "hostname"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Science/Research",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Security",
15
+ ]
16
+ # Imported only when a grant name is judged, so the guest image and the Lima VM still
17
+ # import this module from a bare PYTHONPATH. Pinned: the bundled list decides the verdict.
18
+ dependencies = ["tldextract==5.3.2"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/AlexanderMattTurner/agent-glovebox"
22
+ Source = "https://github.com/AlexanderMattTurner/agent-glovebox"
23
+
24
+ [build-system]
25
+ # scripts/write-python-pins.mjs writes this version from the root pyproject's dev pin;
26
+ # an edit here is overwritten by the next `pnpm install` or commit.
27
+ requires = ["hatchling==1.32.0"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/glovebox_hosts"]
@@ -0,0 +1,635 @@
1
+ """The one spelling of a hostname, and the host rules every glovebox reader applies.
2
+
3
+ PROBLEM CLASS — the launcher, the host proxy's verdict service, the WebFetch hook, the
4
+ driver's evidence reader and the evals each ask "which host is this". Answered twice, a
5
+ host one layer admits reads as another host to the next. Its import needs the standard
6
+ library only, so the guest, the Lima VM and a published package all import this one file.
7
+ Judging a grant name imports tldextract, which only a host that renders a policy carries.
8
+
9
+ The WebFetch hook imports it under the host's own python3, which is 3.9 on a stock Mac,
10
+ so every `X | None` annotation is quoted: evaluated at definition time, it raises there.
11
+ """
12
+
13
+ import argparse
14
+ import functools
15
+ import ipaddress
16
+ import json
17
+ import re
18
+ import string
19
+ import sys
20
+ from collections.abc import Callable, Iterable, Iterator
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, NamedTuple, overload
23
+
24
+ if TYPE_CHECKING:
25
+ import tldextract
26
+
27
+
28
+ class HostError(ValueError):
29
+ """A string that names no host this tree can rule on, or no readable authority."""
30
+
31
+
32
+ def canonical_host(host: str) -> str:
33
+ """HOST as the one spelling every comparison uses: ASCII, stripped, lowercased.
34
+
35
+ INVARIANT: a name two readers could fold apart is refused, never folded. `str.lower`
36
+ turns U+212A KELVIN SIGN into `k`, and `str.strip` removes U+00A0, so the ASCII read
37
+ runs first. A trailing dot is refused because no allowlist entry carries one.
38
+ """
39
+ if not host.isascii():
40
+ raise HostError(f"{host!r} is not an ASCII hostname")
41
+ host = host.strip().lower()
42
+ if not host or host.startswith(".") or host.endswith("."):
43
+ raise HostError(f"{host!r} is empty or starts or ends with a dot")
44
+ try:
45
+ return str(ipaddress.ip_address(host))
46
+ except ValueError:
47
+ return host
48
+
49
+
50
+ @overload
51
+ def split_authority(authority: str, default_port: int) -> tuple[str, int]: ...
52
+ @overload
53
+ def split_authority(
54
+ authority: str, default_port: None = None
55
+ ) -> "tuple[str, int | None]": ...
56
+ def split_authority(
57
+ authority: str, default_port: "int | None" = None
58
+ ) -> "tuple[str, int | None]":
59
+ """The canonical host and the port of `host`, `host:port` or `[v6]:port`.
60
+
61
+ Userinfo, a path, a query, a fragment, any whitespace, an unbalanced bracket and a
62
+ port outside 1-65535 are refused: each hands the host rule one name while a client
63
+ dials another (`user@gateway` reads as `gateway`).
64
+ """
65
+ if any(mark in authority for mark in "@?#/") or any(
66
+ character.isspace() for character in authority
67
+ ):
68
+ raise HostError(
69
+ f"invalid authority: {authority!r} carries userinfo, a path or whitespace"
70
+ )
71
+ if authority.startswith("["):
72
+ host, closer, rest = authority[1:].partition("]")
73
+ if not closer or (rest and not rest.startswith(":")):
74
+ raise HostError(
75
+ f"invalid authority: {authority!r} is not a bracketed address"
76
+ )
77
+ # INVARIANT: brackets hold only an IPv6 literal with no zone ID, so `[[]`,
78
+ # `[1.2.3.4]` or `[fe80::1%[]` never reaches the host rule as another name.
79
+ try:
80
+ zoned = ipaddress.IPv6Address(host).scope_id is not None
81
+ except ValueError as exc:
82
+ raise HostError(
83
+ f"invalid authority: {authority!r} is not a bracketed IPv6 literal"
84
+ ) from exc
85
+ if zoned:
86
+ raise HostError(f"invalid authority: {authority!r} names an IPv6 zone")
87
+ port = rest[1:]
88
+ elif "[" in authority or "]" in authority:
89
+ raise HostError(f"invalid authority: {authority!r} is not a bracketed address")
90
+ else:
91
+ host, _, port = authority.partition(":")
92
+ if not host:
93
+ raise HostError(f"invalid authority: {authority!r} names no host")
94
+ host = canonical_host(host)
95
+ if not port:
96
+ return host, default_port
97
+ # `isascii` before `isdigit`: `²` is a digit to str.isdigit() and not to int().
98
+ if not port.isascii() or not port.isdigit() or not 1 <= int(port) <= 65535:
99
+ raise HostError(f"invalid authority: {authority!r} names no port in 1-65535")
100
+ return host, int(port)
101
+
102
+
103
+ def render_authority(host: str, port: "int | None") -> str:
104
+ """HOST as an authority, PORT appended when not None, an IPv6 literal bracketed.
105
+
106
+ `split_authority` strips the brackets, and `2001:db8::1:443` names neither the
107
+ address nor the port, so every authority this tree writes goes through here.
108
+ """
109
+ literal = f"[{host}]" if ":" in host else host
110
+ return literal if port is None else f"{literal}:{port}"
111
+
112
+
113
+ def _without_port(authority: str) -> str:
114
+ """AUTHORITY less a trailing `:digits` port and the brackets round the host left."""
115
+ try:
116
+ ipaddress.ip_address(authority)
117
+ except ValueError:
118
+ head, colon, tail = authority.rpartition(":")
119
+ if colon and tail.isascii() and tail.isdigit():
120
+ return head[1:-1] if head.startswith("[") and head.endswith("]") else head
121
+ return authority
122
+
123
+
124
+ def record_authority(authority: str) -> "tuple[str, int | None]":
125
+ """The canonical host and port a policy-log entry's AUTHORITY names.
126
+
127
+ A refused authority is what the agent typed. One `split_authority` rejects (a bare IPv6
128
+ literal, a port past 65535) keeps its host with the port dropped. Only an ASCII host is
129
+ lowercased, because `str.lower` folds U+212A KELVIN SIGN to `k`; ASCII space is stripped.
130
+ """
131
+ # ASCII whitespace only: a Unicode space stripped here would fold a name the
132
+ # verdict service refuses into the ASCII host it would have admitted.
133
+ authority = authority.strip(string.whitespace)
134
+ try:
135
+ return split_authority(authority)
136
+ except HostError:
137
+ pass
138
+ authority = _without_port(authority)
139
+ try:
140
+ return canonical_host(authority), None
141
+ except HostError:
142
+ return (authority.lower() if authority.isascii() else authority), None
143
+
144
+
145
+ # Every matcher below re-canonicalises its whole entry table per host, and the failed
146
+ # `ip_address` parse each hostname pays made a policy render quadratic. Only entries reach
147
+ # this cache, never a request host, so no stream of request hosts can evict the table.
148
+ # A call that raises is never cached, so a malformed entry raises on every call.
149
+ _canonical_entry = functools.lru_cache(maxsize=4096)(canonical_host)
150
+
151
+
152
+ def _canonical_entries(entries: Iterable[str]) -> Iterator[tuple[str, str]]:
153
+ """Each non-blank entry as spelled, beside its canonical form.
154
+
155
+ A blank entry is skipped because an empty suffix matches every host. A malformed
156
+ entry raises, because a matcher that quietly skipped it would miss a bar.
157
+ """
158
+ for entry in entries:
159
+ if entry.strip():
160
+ yield entry, _canonical_entry(entry)
161
+
162
+
163
+ def is_wildcard(entry: str) -> bool:
164
+ """Whether ENTRY is spelled `*.S`, the marker of a grant over every name below S."""
165
+ return entry.startswith("*.")
166
+
167
+
168
+ def _below_wildcard(host: str, canonical: str) -> bool:
169
+ """Whether CANONICAL is a `*.S` entry and HOST sits any depth below S, never at S."""
170
+ # allow-affix-match: `canonical[1:]` is `.S`, so the leading dot anchors a label boundary.
171
+ return is_wildcard(canonical) and host.endswith(canonical[1:])
172
+
173
+
174
+ def granting_entries(host: str, entries: Iterable[str]) -> list[str]:
175
+ """Every entry whose GRANT reaches HOST — the entry itself, or one label below.
176
+
177
+ A named grant reaches as far as one certificate wildcard covers: `example.com`
178
+ names `api.example.com` and not `api.eu.example.com`. A `*.S` entry reaches every
179
+ depth below S and never S itself. A caller needing one entry takes the LONGEST.
180
+ A policy row marked exact narrows its entry to itself; the render marks a public
181
+ suffix's row so (`grant_is_exact`), and every row reader honours the mark.
182
+ """
183
+ host = canonical_host(host)
184
+ parent = host.partition(".")[2]
185
+ return [
186
+ entry
187
+ for entry, canonical in _canonical_entries(entries)
188
+ if canonical in (host, parent) or _below_wildcard(host, canonical)
189
+ ]
190
+
191
+
192
+ def constraining_entries(host: str, entries: Iterable[str]) -> list[str]:
193
+ """Every entry whose BAR reaches HOST — the entry itself, or any depth below it.
194
+
195
+ A bar someone can step under bars nothing, so this rule runs the whole subtree.
196
+ """
197
+ host = canonical_host(host)
198
+ return [
199
+ entry
200
+ for entry, canonical in _canonical_entries(entries)
201
+ if host == canonical
202
+ # allow-affix-match: the leading dot anchors the match at a label boundary.
203
+ or host.endswith("." + canonical)
204
+ or _below_wildcard(host, canonical)
205
+ ]
206
+
207
+
208
+ def governing_entry(
209
+ host: str,
210
+ entries: Iterable[str],
211
+ reach: Callable[[str, Iterable[str]], list[str]],
212
+ ) -> "str | None":
213
+ """The LONGEST entry whose REACH covers HOST, or None when none does.
214
+
215
+ REACH is required: `granting_entries` and `constraining_entries` reach differently.
216
+ Longest by the canonical spelling less a `*.` marker, a named entry winning a tie, so
217
+ neither padding nor entry order picks the answer. Every longest-match rule names it here.
218
+ """
219
+ matches = reach(host, entries)
220
+ if not matches:
221
+ return None
222
+ return max(matches, key=_entry_depth)
223
+
224
+
225
+ def _entry_depth(entry: str) -> tuple[int, bool]:
226
+ canonical = _canonical_entry(entry)
227
+ return len(wildcard_suffix(canonical)), not is_wildcard(canonical)
228
+
229
+
230
+ def family_tier(host: str, tiers: dict[str, str]) -> str:
231
+ """The tier of the entry in TIERS that governs HOST, or "".
232
+
233
+ TIERS naming one host under two spellings is refused, as the gateway refuses it: a
234
+ tier chosen by key order could grant what the host proxy denies.
235
+ """
236
+ spelled: dict[str, str] = {}
237
+ for entry, canonical in _canonical_entries(tiers):
238
+ if canonical in spelled:
239
+ raise HostError(f"{entry!r} and {spelled[canonical]!r} are one host")
240
+ spelled[canonical] = entry
241
+ governing = governing_entry(host, tiers, granting_entries)
242
+ return "" if governing is None else tiers[governing]
243
+
244
+
245
+ _LABEL = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?")
246
+ _PLAIN = re.compile(r"[A-Za-z0-9._-]*")
247
+
248
+
249
+ def valid_domain_name(name: str) -> bool:
250
+ """Whether NAME is a bare DNS hostname a workspace or a grant may name.
251
+
252
+ Dot-separated labels of 1-63 letters, digits and hyphens, each edged with a letter
253
+ or digit, 253 characters at most, and at least one dot. The last label starts with
254
+ a letter, which refuses every IPv4 spelling inet_aton(3) dials, octal and hex
255
+ included (`0177.0.0.1` reaches 127.0.0.1). A URL, a port, a wildcard, whitespace
256
+ and a trailing dot all fail, so no such value seeds an allowlist entry.
257
+ """
258
+ if len(name) > 253 or not name.isascii():
259
+ return False
260
+ labels = name.split(".")
261
+ return (
262
+ len(labels) > 1
263
+ and labels[-1][:1].isalpha()
264
+ and all(len(label) <= 63 and _LABEL.fullmatch(label) for label in labels)
265
+ )
266
+
267
+
268
+ def wildcard_suffix(name: str) -> str:
269
+ """NAME less a leading `*.`: the suffix S a `*.S` grant names, or NAME itself."""
270
+ return name.removeprefix("*.")
271
+
272
+
273
+ # Names judged public suffixes whatever the bundled list says. Each value names what the
274
+ # label below the suffix belongs to. The first is pinned so the refusal does not depend on
275
+ # upstream list churn; the bundled snapshot carries it today. The other two the list lacks.
276
+ _PUBLIC_SUFFIX_SUPPLEMENT = {
277
+ "blob.core.windows.net": "an Azure storage account",
278
+ "web.core.windows.net": "an Azure storage account's static site",
279
+ "modal.run": "a Modal workspace",
280
+ }
281
+
282
+
283
+ @functools.cache
284
+ def _suffix_of() -> "Callable[[str], str]":
285
+ """The public suffix of a host, read from the Public Suffix List tldextract bundles.
286
+
287
+ Offline: `suffix_list_urls=()` and `cache_dir=None` read the snapshot and write
288
+ nothing. `include_psl_private_domains=True` admits the list's private section, where
289
+ `github.io` and `blob.core.windows.net` sit. ImportError where tldextract is absent.
290
+ """
291
+ extract = _extractor()
292
+ return lambda host: extract(host).suffix
293
+
294
+
295
+ @functools.cache
296
+ def _extractor() -> "tldextract.TLDExtract":
297
+ """The offline tldextract reader `_suffix_of` and `suffix_below` share."""
298
+ import tldextract # pylint: disable=import-outside-toplevel
299
+
300
+ return tldextract.TLDExtract(
301
+ suffix_list_urls=(), include_psl_private_domains=True, cache_dir=None
302
+ )
303
+
304
+
305
+ @functools.cache
306
+ def _suffix_rules() -> tuple[str, ...]:
307
+ """Every public suffix rule, folded and sorted, a `*.S` rule kept as written. An
308
+ exception rule (`!host`) names a host that is NOT a suffix, so none is kept."""
309
+ rules = {rule.lower() for rule in _extractor().tlds if not rule.startswith("!")}
310
+ # The list spells an internationalized rule in Unicode, and a grant names it in
311
+ # punycode, so each rule is kept in both spellings.
312
+ spelled = rules | {_ascii_rule(rule) for rule in rules}
313
+ return tuple(sorted(spelled | set(_PUBLIC_SUFFIX_SUPPLEMENT)))
314
+
315
+
316
+ def _ascii_rule(rule: str) -> str:
317
+ """RULE with each non-ASCII label in its punycode spelling, or RULE unchanged where
318
+ a label has none."""
319
+ try:
320
+ return ".".join(
321
+ label if label.isascii() else label.encode("idna").decode("ascii")
322
+ for label in rule.split(".")
323
+ )
324
+ except UnicodeError:
325
+ return rule
326
+
327
+
328
+ def suffix_below(host: str) -> "str | None":
329
+ """A public suffix rule strictly below HOST, or None. A `*.S` grant reaches every
330
+ depth below S, so one over such a host also reaches the names strangers register
331
+ under that suffix: `*.amazonaws.com` reaches `bucket.s3.amazonaws.com`.
332
+ ImportError where tldextract is absent."""
333
+ dotted = "." + wildcard_suffix(host.strip().lower())
334
+ return next((rule for rule in _suffix_rules() if rule.endswith(dotted)), None)
335
+
336
+
337
+ def is_public_suffix(host: str) -> bool:
338
+ """True when HOST's grant reaches a name a stranger registers a label below.
339
+
340
+ Folded, as every host rule folds, so `Modal.run` is judged as `modal.run`. The probe
341
+ label is what reads the list's WILDCARD rules: `*.compute.amazonaws.com` leaves
342
+ `compute.amazonaws.com` looking like an ordinary domain while the one label below it
343
+ is a different stranger's host each time. A `*.S` entry is read as S.
344
+ """
345
+ folded = wildcard_suffix(host.strip().lower())
346
+ if folded in _PUBLIC_SUFFIX_SUPPLEMENT:
347
+ return True
348
+ reached = f"a.{folded}"
349
+ return _suffix_of()(reached) in (folded, reached)
350
+
351
+
352
+ def grant_name_refusal(name: str) -> "str | None":
353
+ """Why NAME is no host a grant may name, as a phrase that follows the name, or None.
354
+
355
+ A grant names a `valid_domain_name`, or `*.S` for one. `*.S` reaches every name
356
+ below S, so it is refused when S is a public suffix, or when one lies below S
357
+ (`suffix_below`): either way it hands the grant to strangers. A plain public suffix
358
+ such as `httpbin.org` may be granted, because `grant_is_exact` holds its grant to
359
+ that one host instead of one label below it.
360
+
361
+ INVARIANT: without tldextract every name is refused, because a name this python cannot
362
+ judge may be a public suffix, and a grant over one reaches strangers.
363
+ """
364
+ if not valid_domain_name(wildcard_suffix(name)):
365
+ return (
366
+ "is not a valid hostname (letters, digits, dots and hyphens, with at least"
367
+ " one dot) or a `*.` wildcard over one"
368
+ )
369
+ try:
370
+ public = is_public_suffix(name)
371
+ below = suffix_below(name) if is_wildcard(name) and not public else None
372
+ except ImportError:
373
+ return (
374
+ f"cannot be judged: {sys.executable} cannot import tldextract to tell"
375
+ " whether it is, or spans, a public suffix. Install tldextract==5.3.2 into"
376
+ " that python"
377
+ )
378
+ if public and is_wildcard(name):
379
+ return (
380
+ "is a wildcard over a public suffix, so it reaches every name a stranger"
381
+ " registers below it. Name the one host you meant"
382
+ )
383
+ if below is not None:
384
+ return (
385
+ f"is a wildcard over a name with the public suffix {below!r} below it, so"
386
+ " it reaches every name a stranger registers there. Name the one host you"
387
+ " meant"
388
+ )
389
+ return None
390
+
391
+
392
+ def grant_is_exact(name: str) -> bool:
393
+ """Whether a grant on NAME reaches NAME alone, never the one label below it that a
394
+ plain grant reaches: NAME is a plain public suffix, whose labels strangers register.
395
+
396
+ ImportError where tldextract is absent: an answer of False there would let a public
397
+ suffix reach the strangers below it, so the caller must refuse rather than guess.
398
+ """
399
+ if is_wildcard(name):
400
+ return False
401
+ return is_public_suffix(name)
402
+
403
+
404
+ def valid_grant_name(name: str) -> bool:
405
+ """Whether NAME is a host a grant may name: `grant_name_refusal` finds no fault.
406
+
407
+ `*.S` reaches every name below S and never S itself. S keeps the one-dot floor, so
408
+ `*`, `*.com`, `**.example.com` and `a.*.example.com` all fail, and so does `*.co.uk`.
409
+ `co.uk` itself passes, as a grant `grant_is_exact` holds to that one host.
410
+ """
411
+ return grant_name_refusal(name) is None
412
+
413
+
414
+ def lookalike(name: str) -> bool:
415
+ """Whether NAME carries an `xn--` label or a character outside `[A-Za-z0-9._-]`.
416
+
417
+ Those are the shapes a homoglyph of an allowlisted host hides behind. The punycode
418
+ test ignores case, because DNS does: `XN--80AK6AA92E.com` is the same host. A
419
+ leading `*.` is a wildcard grant's marker, so the test reads the suffix below it.
420
+ """
421
+ name = wildcard_suffix(name)
422
+ return "xn--" in name.lower() or not _PLAIN.fullmatch(name)
423
+
424
+
425
+ # The annotation a Kata pod names its own egress grants in, and the keys one grant object
426
+ # may carry. A reader would ignore any other key, so a key meant to narrow a grant would
427
+ # widen it unread: the grant is refused instead.
428
+ ANNOTATION_GRANTS = "glovebox.dev/egress-grants"
429
+ GRANT_KEYS = frozenset({"host", "rw"})
430
+ DEFAULT_PORT = 443 # the port an address grant opens when it names none
431
+
432
+
433
+ class GrantRefused(ValueError):
434
+ """A grant this tree will not open, with the sentence that says why."""
435
+
436
+
437
+ @dataclass(frozen=True)
438
+ class AddressGrant:
439
+ """One address grant as written: a network and the one port it opens."""
440
+
441
+ network: "ipaddress.IPv4Network | ipaddress.IPv6Network"
442
+ port: int
443
+
444
+
445
+ def address_grant(entry: str) -> "AddressGrant | None":
446
+ """ENTRY as an address grant, None when it names a host, GrantRefused when malformed.
447
+
448
+ `ADDR`, `ADDR/PREFIX`, either with `:PORT`; IPv6 takes `[ADDR[/PREFIX]]:PORT` for a
449
+ port. The port defaults to DEFAULT_PORT. Host bits set under the prefix are refused.
450
+ How wide a network one grant may name is the caller's to judge.
451
+ """
452
+ text = entry.strip()
453
+ port_text: str | None = None
454
+ if text.startswith("["):
455
+ inner, closer, rest = text[1:].partition("]")
456
+ if not closer or (rest and not rest.startswith(":")):
457
+ raise GrantRefused(f"{entry!r} is not a bracketed IPv6 grant")
458
+ text, port_text = inner, rest[1:] if rest else None
459
+ elif text.count(":") == 1:
460
+ text, _, port_text = text.partition(":")
461
+ try:
462
+ network = ipaddress.ip_network(text, strict=True)
463
+ except ValueError as bad:
464
+ if "/" in text or ":" in text or entry.strip().startswith("["):
465
+ raise GrantRefused(f"{entry!r} is not an address grant: {bad}") from bad
466
+ return None
467
+ return AddressGrant(network=network, port=grant_port(port_text, entry))
468
+
469
+
470
+ def grant_port(text: "str | None", entry: str) -> int:
471
+ """TEXT as a port; None, a port the entry never wrote, is DEFAULT_PORT. A port written
472
+ empty (`ADDR:`) is refused, never read as the default."""
473
+ if text is None:
474
+ return DEFAULT_PORT
475
+ if not (text.isascii() and text.isdigit() and 1 <= int(text) <= 65535):
476
+ raise GrantRefused(f"{entry!r} names no port in 1-65535")
477
+ return int(text)
478
+
479
+
480
+ def grant_host_refusal(host: str) -> "str | None":
481
+ """Why HOST is no name a pod's grant may carry, as a whole phrase, or None: the grant
482
+ name rules plus `lookalike`. The forbidden-host bar applies at the render."""
483
+ fault = grant_name_refusal(host)
484
+ if fault is not None:
485
+ return f"{host!r} {fault}"
486
+ if lookalike(host):
487
+ return f"{host!r} carries punycode or non-ASCII characters"
488
+ return None
489
+
490
+
491
+ class PodGrants(NamedTuple):
492
+ """What a pod's grants annotation names: each host at `ro` or `rw`, and each address."""
493
+
494
+ hosts: dict[str, str]
495
+ addresses: list[AddressGrant]
496
+
497
+
498
+ def _as_text(value: object) -> str:
499
+ """A grant field as text. A non-string renders as JSON, so a boolean reads `true`."""
500
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
501
+
502
+
503
+ def _grant_entries(text: str) -> list[tuple[str, str]]:
504
+ """Each `(entry, tier)` TEXT names: `"host"`, or `{"host": ..., "rw": ...}` where an
505
+ absent or false `rw` reads as read-only, the weaker."""
506
+ try:
507
+ listed = json.loads(text)
508
+ except (ValueError, RecursionError) as bad:
509
+ # RecursionError: an annotation nested deeper than the parser's stack.
510
+ raise GrantRefused(f"{ANNOTATION_GRANTS} is not JSON: {bad}") from bad
511
+ if not isinstance(listed, list):
512
+ raise GrantRefused(f"{ANNOTATION_GRANTS} is not a JSON list of hosts")
513
+ for raw in listed:
514
+ unknown = sorted(set(raw) - GRANT_KEYS) if isinstance(raw, dict) else []
515
+ if unknown:
516
+ raise GrantRefused(
517
+ f"{ANNOTATION_GRANTS}: a grant carries {unknown[0]!r}; "
518
+ f"a grant's keys are {' and '.join(sorted(GRANT_KEYS))}"
519
+ )
520
+ entries: list[tuple[str, str]] = []
521
+ for raw in listed:
522
+ grant = raw if isinstance(raw, dict) else {"host": raw}
523
+ rw_value = grant.get("rw")
524
+ rw = _as_text(False if rw_value is None or rw_value is False else rw_value)
525
+ host = _as_text(grant.get("host"))
526
+ if rw not in ("true", "false"):
527
+ raise GrantRefused(
528
+ f"{ANNOTATION_GRANTS}: {host}'s rw is {rw}, not a boolean"
529
+ )
530
+ entries.append((host, "rw" if rw == "true" else "ro"))
531
+ return entries
532
+
533
+
534
+ def pod_grants(text: "str | None") -> PodGrants:
535
+ """What a Kata pod's `glovebox.dev/egress-grants` annotation TEXT grants, as every
536
+ node and admission webhook must read it. None, no annotation, grants nothing.
537
+
538
+ INVARIANT: the one reader of this annotation. GrantRefused names the first entry the
539
+ node would refuse the whole pod for. An address must carry `"rw": true`, since a
540
+ tunnel's bytes go unread. How wide an address may be is the node's own setting.
541
+ """
542
+ if text is None:
543
+ return PodGrants(hosts={}, addresses=[])
544
+ hosts: dict[str, str] = {}
545
+ addresses: list[AddressGrant] = []
546
+ for entry, tier in _grant_entries(text):
547
+ if entry != entry.strip():
548
+ # address_grant would strip it, and the name rules refuse it: judge one
549
+ # spelling, so a padded address is refused as a padded name is.
550
+ raise GrantRefused(
551
+ f"{ANNOTATION_GRANTS}: {entry!r} carries surrounding whitespace"
552
+ )
553
+ try:
554
+ grant = address_grant(entry)
555
+ except GrantRefused as bad:
556
+ raise GrantRefused(f"{ANNOTATION_GRANTS}: {bad}") from bad
557
+ if grant is not None:
558
+ if tier != "rw":
559
+ raise GrantRefused(
560
+ f"{ANNOTATION_GRANTS}: {entry} is an address, which opens a raw "
561
+ 'tunnel; grant it with "rw": true'
562
+ )
563
+ addresses.append(grant)
564
+ continue
565
+ refusal = grant_host_refusal(entry)
566
+ if refusal is not None:
567
+ raise GrantRefused(f"{ANNOTATION_GRANTS}: {refusal}")
568
+ hosts[entry] = tier
569
+ return PodGrants(hosts=hosts, addresses=addresses)
570
+
571
+
572
+ def _name_check(args: argparse.Namespace) -> None:
573
+ """Print `malformed`, `lookalike` or `ok` for NAME, the first fault found.
574
+
575
+ A shell caller maps the word to its own message and refuses on any other output,
576
+ so an interpreter that cannot run refuses too.
577
+ """
578
+ if not args.lookalike_only and not valid_domain_name(args.name):
579
+ print("malformed")
580
+ elif lookalike(args.name):
581
+ print("lookalike")
582
+ else:
583
+ print("ok")
584
+
585
+
586
+ def _constraining(args: argparse.Namespace) -> None:
587
+ """Print each host on stdin that the denylist file bars, in one batch per launch."""
588
+ with open(args.entries, encoding="utf-8") as handle:
589
+ entries = handle.read().splitlines()
590
+ # A blank line is the batch's framing, as it is in the entry file; a host that is
591
+ # not blank and has no canonical spelling still raises.
592
+ for host in filter(str.strip, sys.stdin.read().splitlines()):
593
+ if constraining_entries(host, entries):
594
+ print(host)
595
+
596
+
597
+ def _tier(args: argparse.Namespace) -> None:
598
+ """Print `{"tier": ..., "error": ...}` for HOST under the allowlist's `domains`,
599
+ plus the API hosts of the `--agent` the caller runs inside."""
600
+ with open(args.allowlist, encoding="utf-8") as handle:
601
+ document = json.load(handle)
602
+ domains = document["domains"]
603
+ if args.agent:
604
+ domains = {
605
+ **domains,
606
+ **document["agents"][args.agent]["api_host_allowlist_entries"],
607
+ }
608
+ try:
609
+ canonical_host(args.host)
610
+ except HostError as exc:
611
+ print(json.dumps({"tier": None, "error": str(exc)}))
612
+ return
613
+ # An entry that names no host is the allowlist's defect, so it raises past here.
614
+ print(json.dumps({"tier": family_tier(args.host, domains) or None, "error": None}))
615
+
616
+
617
+ def main(argv: list[str]) -> None:
618
+ """`constraining ENTRIES`, `tier [--agent NAME] ALLOWLIST HOST` and `name-check [--lookalike-only]
619
+ NAME`, for callers outside Python."""
620
+ parser = argparse.ArgumentParser(prog="glovebox_hosts")
621
+ commands = parser.add_subparsers(required=True)
622
+ constraining = commands.add_parser("constraining")
623
+ constraining.add_argument("entries")
624
+ constraining.set_defaults(run=_constraining)
625
+ tier = commands.add_parser("tier")
626
+ tier.add_argument("--agent", default="")
627
+ tier.add_argument("allowlist")
628
+ tier.add_argument("host")
629
+ tier.set_defaults(run=_tier)
630
+ check = commands.add_parser("name-check")
631
+ check.add_argument("--lookalike-only", action="store_true")
632
+ check.add_argument("name")
633
+ check.set_defaults(run=_name_check)
634
+ args = parser.parse_args(argv)
635
+ args.run(args)
@@ -0,0 +1,7 @@
1
+ """`python -m glovebox_hosts`: the host rule's command line, for callers outside Python."""
2
+
3
+ import sys
4
+
5
+ from glovebox_hosts import main
6
+
7
+ main(sys.argv[1:])