# Changelog

All notable changes to this project will be documented in this file. Each version listed corresponds to a release published on [PyPI](<https://pypi.org/project/tethered/>).

The format is based on [Keep a Changelog](<https://keepachangelog.com/>), and this project adheres to [Semantic Versioning](<https://semver.org/>).

## [1.0.0](<https://github.com/shcherbak-ai/tethered/compare/v0.5.3...v1.0.0>) — 2026-10-13

Everything below is relative to 0.5.3. Most of this release closes cases in which a policy was in place and not applied: an event loop or a resolver that tethered does not see, a Python child or an interpreter that started without the policy, a rule that was read otherwise than it was written. Where 0.5.3 went on unenforced, 1.0.0 refuses, when the policy is set or at the call, and says what to change. A deployment that ran on 0.5.3 can therefore stop at start-up after the upgrade with no change of its own. Every change that can do that is under **Breaking changes**, whatever kind of change it is, a security fix included. No function, parameter or exception was renamed or removed.

### Upgrading from 0.5.x

Read the [1.0.0 migration guide](<https://github.com/shcherbak-ai/tethered/blob/main/docs/migrating-to-v1.md>) before upgrading. It takes the breaking changes below by what a deployment uses (an asyncio server, child processes and `external_subprocess_policy="block"`, locked mode, a log-only rollout), with the message each one shows and the change that puts it right, and it lists the fixes that are not breaking but refuse something 0.5.3 let through.

### Breaking changes

Each entry can break a configuration or calling code that worked on 0.5.3, and ends with what to do. An entry that is a security fix as well is here and not under Security.

#### Python version

- **Breaking: Python 3.10 is no longer supported.** tethered now requires Python 3.11 or later, and no wheels are built for 3.10, which reached the end of its security support in October 2026. `pip` keeps an environment on Python 3.10 at 0.5.3, without the fixes listed under Security. What to do: move to Python 3.11 or later.

#### Event loops and resolvers

- **Breaking: tethered no longer runs next to uvloop.** Nothing that goes through a uvloop event loop raises an audit event, so a policy was applied to none of the asynchronous networking of a process that ran on one, and uvicorn picks uvloop whenever it is installed (`uvicorn[standard]`, `fastapi[standard]`). Two things changed. While a policy that is not `log_only` is active, `import uvloop` raises `ImportError`; a server that takes uvloop only when the import works (uvicorn, gunicorn's `UvicornWorker`) then runs on the standard asyncio event loop, where the policy is enforced, and tethered logs one warning. And when uvloop is already loaded, `activate()` raises `RuntimeError`, logs the same message as an error, and changes nothing: the server does not start. Before, the policy was activated and silently not applied to anything that went through the loop. A `log_only` policy and a `scope()` with no policy under it are not affected: they refuse nothing and warn once. Nothing is said or refused where uvloop is not installed. Which of the two a deployment meets depends on whether the server imports the application before it picks its loop: with uvicorn 0.54 a plain `uvicorn main:app` falls back and works, while `--workers`, `--reload`, older uvicorn releases, hypercorn's uvloop worker, the `sanic` command and granian with uvloop installed do not start. What to do: name the standard loop in the server's configuration (`uvicorn main:app --loop asyncio`, `UVICORN_LOOP=asyncio` for the `uvicorn` command, `loop="asyncio"` in `uvicorn.run()`), or call `activate()` in a launcher script before the server is imported, or uninstall uvloop ([`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md#servers-and-interpreter-pools>) has each server).
- **Breaking: pycares, and with it aiodns, is not loaded while a policy is active.** c-ares looks names up in C, where tethered sees neither the lookup nor which host an address belongs to. With it, every host that the allow list names was refused by its address, while the lookup of a forbidden name still left the process; aiohttp resolves that way whenever aiodns is installed (`aiohttp[speedups]`). While a policy that is not `log_only` is active, `import pycares` and `import aiodns` now raise `ImportError`, logged once at info level. aiohttp falls back to its threaded resolver on its own, and is then enforced like any other client. A pycares that was imported before `activate()` is left alone, and `activate()` is not refused because of it. What to do: nothing for aiohttp, as long as `activate()` comes before aiohttp is imported; where it cannot (`python -m aiohttp.web`, gunicorn's aiohttp worker class), pass `aiohttp.TCPConnector(resolver=aiohttp.ThreadedResolver())`; and code that needs pycares or aiodns itself has to import it before `activate()` and allow the addresses it connects to.

#### Allow rules

- **Breaking: malformed allow rules raise `ValueError`** at `activate()` / `scope()`. Before, they were accepted and either never matched or allowed more than was written. Affected: a non-numeric, empty, negative or out-of-range port (`host:abc`, `host:`, `host:-1`); text or a bad port after the `]` of a bracketed IP address (`[2001:db8::1]:abc` used to allow the address on every port); an invalid network prefix; a rule with no host (`:443`); a hostname containing `:`, which includes wildcards over IPv6 text such as `fe80::*` (use CIDR); a dot after the port (`host:443.`) or after a bracketed IP (`[::1].`); and a `*` pattern whose only other content is a character set (`*[!/]`), which matched every destination. What to do: correct the rule; for an IPv6 range use CIDR notation (`fe80::/10`).

#### Child processes

- **Breaking: more launches are treated as external**, so they are warned about or, under `external_subprocess_policy="block"`, refused. These are: any Python launch when no `tethered.pth` is in a directory `site.py` processes (an editable install, or `pip install --target` + `PYTHONPATH`), or when the file there is not tethered's own or, from Python 3.15, has a `tethered.start` file beside it (both under Security); on POSIX, a launch whose `argv[0]` is not `sys.executable` (`["python", ...]` with `executable=sys.executable`); a launch whose interpreter options cannot be parsed; `-s` / `-I` launches when tethered is installed in the per-user site-packages; and a Python launch through `os.spawn*` / `os.posix_spawn` with an explicit environment that lacks the policy variable (an `os.exec*` with such an environment is carried out with the variable added, see Fixed). Under uWSGI started without `--py-sys-executable`, where `sys.executable` is the uwsgi binary and not an interpreter, every launch counts as external (see Security). `multiprocessing` workers started after `multiprocessing.set_executable()` named another interpreter, and direct `_winapi.CreateProcess` calls, are now subject to the policy as well (Windows; `multiprocessing` on POSIX from Python 3.14). What to do: install tethered into site-packages (a regular install, not editable and not `--target`) so that Python children inherit the policy, and under uWSGI name the interpreter with `--py-sys-executable`; `external_subprocess_policy` then decides whether the launches that remain external are logged (`"warn"`), ignored (`"allow"`) or refused (`"block"`).
- **Breaking: a child process refuses to start when it cannot establish a locked or fail-closed policy.** When the inherited policy has `locked` or `fail_closed` set and the child's bootstrap cannot establish it (an invalid payload, a rule the child rejects, the C extension missing), the interpreter exits at startup with status 1. Before, it logged a warning and ran without the policy, or fell back to unlocked enforcement. Without either flag nothing changes: a warning is logged and the child continues. An interpreter created inside the process (a subinterpreter) is the one exception for `locked`: it joins the lock of its process or, where none is held, enforces the policy unlocked, and one that cannot join starts all the same and stays outside the lock (see Locked mode below, and Security). What to do: give child interpreters the same tethered installation as the parent, C extension included.

#### Locked mode

- **Breaking: under a locked policy, an interpreter that has not joined the lock gets no network.** The lock is held once per process, by the interpreter that took it. An interpreter created while it is held joins it as it starts, through the `tethered.pth` bootstrap: it gets its own locked copy of the policy, checked for tampering like the lock holder's, reaches what the policy allows and is refused the rest, and can neither deactivate nor replace the policy nor change `_TETHERED_CHILD_POLICY`. `concurrent.futures.InterpreterPoolExecutor` workers join the same way. Every other interpreter gets no connections, lookups or process launches and cannot change `tethered.pth`, `tethered.start` or the policy variable for as long as the lock is held, whatever the allow list says: one that existed before the lock was taken, one that started without the bootstrap (tethered not installed in site-packages, the variable missing from the environment, a process started with `-S`), and one whose join failed. Such an interpreter can still be created and can compute. Before, a subinterpreter was limited only by its own unlocked copy of the policy, which it could switch off with `tethered.deactivate()`, and by nothing once the variable had been removed. A locked `log_only` policy lets every interpreter's connections and lookups pass. What to do: take the lock before creating subinterpreters or interpreter pools (for a pool, before its first task is submitted: workers are started on demand), and install tethered into site-packages (a regular install, not editable and not `--target`) so that the bootstrap runs in each new interpreter. Under mod\_wsgi, which creates an application group's interpreter at that group's first request, give each application its own daemon process, or take the lock in the main interpreter at process start with a script of its own (`WSGIImportScript ... application-group=%{GLOBAL}`); uWSGI with several mounted applications needs the same policy locked by the first one ([`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md#mod_wsgi>)).
- **Breaking: under a locked policy, no Python child is launched once the hook is lost.** When `tethered.pth` no longer holds tethered's line at the launch of a Python child (it was emptied, removed or replaced after the policy was locked), the launch raises `SubprocessBlocked`, whatever `external_subprocess_policy` says. The lock promises that no Python child of the interpreter starts without the policy, and it already refused a launch whose process environment had lost the policy variable; a lost hook has the same result for the child. This is also what answers for the routes the file guard cannot see: on Linux, `/proc/self/fd/N` and `os.open(..., dir_fd=...)` still empty the hook under a lock, and the launch that follows is refused. What to do: restart a locked process after tethered was upgraded or removed in its environment, and keep doing both from outside the processes that hold a locked policy.
- **Breaking: lock tokens of more types are rejected, and a tampered policy stays locked.** `lock_token` now also rejects `complex`, `tuple` and `frozenset` values and the singletons `Ellipsis` and `NotImplemented` (`TypeError`), in addition to `str`, `int`, `float`, `bytes` and `bool`. A locked policy that has failed its integrity check can be neither replaced nor deactivated, even with the correct token: `activate()` and `deactivate()` raise `TetheredLocked` until the original objects are restored or the process is restarted. What to do: pass `object()`, or an instance of a class of your own, as the token.
- **Breaking: in locked mode, a failed integrity check on a process launch raises `SubprocessBlocked`**, where it raised `EgressBlocked`. The same check now also refuses the guarded `tethered.pth` operations and `os.putenv` / `os.unsetenv` of the policy variable. What to do: catch `SubprocessBlocked`, or `RuntimeError` (the base class of both exceptions), around process launches.

#### `activate()` and `scope()`

- **Breaking: `activate()` and `scope()` raise `RuntimeError` when another audit hook in the process refuses tethered's hook.** `sys.addaudithook` drops a new hook without an error when an existing hook raises on its event. Before, tethered then reported success with nothing enforced, in locked mode too. What to do: install tethered's hook first, by calling `activate()` or entering a scope before the other hook is added, or have that hook let tethered's through.
- **Breaking: `activate()` raises `ValueError` and changes nothing when the policy is too large to publish to child processes.** Windows limits an environment variable to 32,767 characters, which is roughly 1,500 short rules. Before, the call raised with the new policy already enforcing and nothing published to children. What to do: shorten the allow list, for example by merging addresses into CIDR ranges; a caller that ignored the error no longer ends up with the new policy enforcing in the parent.

#### Log lines

- **Breaking: a log-only policy or scope logs `would block`, where it logged `blocked`.** Under `log_only=True` the call goes ahead, and a line such as `tethered: blocked connect to evil.test:443` read, to whoever looked at the log later, like a connection that was stopped. It is now `tethered: would block connect to evil.test:443`, and the same for lookups and scopes. The lines of an enforcing policy are unchanged. What to do: a log filter or an alert that matches `tethered: blocked` for a log-only policy has to match `tethered: would block`.

### Added

- Python 3.15 support: the test matrix runs on 3.15 and wheels are built for it. The `tethered.pth` bootstrap keeps working there; [PEP 829](<https://peps.python.org/pep-0829/>) deprecates `import` lines in `.pth` files but Python 3.15–3.17 still run them.
- A warning for address-shaped wildcard rules (`10.*`, `192.0.2.*`). Such a rule is a text pattern, so it also allows every hostname that fits it, such as `10.attacker.example`. Matching is unchanged; the warning is logged once per rule per process and points to CIDR notation (`10.0.0.0/8`).
- A one-time warning on Windows when a policy or scope is active and a socket is created under asyncio's default `ProactorEventLoop`. That loop connects through calls that raise no audit event, so raw-IP destinations, port rules and scope connect checks are not enforced on it; run the loop as `asyncio.SelectorEventLoop`. The gap is not new; it is now reported at run time and documented in [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md>).
- A one-time warning when uvloop is loaded next to a `log_only` policy or a `scope()` that has no policy under it: logged when the policy is activated, when a scope is entered, or at the first socket created under either. In those two cases tethered runs next to uvloop and reports nothing that goes through its event loop. Every other policy keeps uvloop out (see Breaking changes).
- A one-time warning from `activate()` when multiprocessing's fork server was started before the policy. A worker of the `forkserver` start method (the default on Linux from Python 3.14) is a copy of the fork server, so such workers hold no policy, under a locked policy either, and tethered can neither refuse nor complete them. The gap is not new; it is now reported at run time. Call `activate()` before the first pool or process is started.
- A one-time hint when a connection is refused to an IP address that no hostname is known for, under a policy that has hostname rules: `tethered: no hostname is known for the refused address ...`. That is what a library produces that resolves names outside Python's `socket` module (aiodns / c-ares imported before the policy, dnspython, eventlet's greendns, gevent's ares resolver): a host the allow list names is then refused by its address. The line says what to change.
- A documentation site, [tethered.shcherbak.ai](<https://tethered.shcherbak.ai/>), built from the pages under `docs/`: a home page and a quick start, the cookbook, the subprocess page, the API reference, the compatibility and architecture pages, the security model, this changelog, and the [1.0.0 migration guide](<https://github.com/shcherbak-ai/tethered/blob/main/docs/migrating-to-v1.md>). The site sets no cookie and asks no third party for anything, and the pages read the same on GitHub. For language models and coding agents it has an index at [`llms.txt`](<https://tethered.shcherbak.ai/llms.txt>), with a Markdown version of every page it lists.
- [`docs/COMPATIBILITY.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COMPATIBILITY.md>): what is enforced, what needs a particular configuration, what tethered refuses at start-up and what it does not see at all, under the servers, task queues and client libraries people deploy: uvicorn, FastAPI, gunicorn and its worker classes, hypercorn, Quart, granian, sanic, aiohttp, daphne and Twisted, tornado, uWSGI, waitress, mod\_wsgi, Flask, Django; Celery, RQ, Dramatiq, Huey, arq, the standard library's process and interpreter pools, supervisord; and 72 arrangements of client libraries. Each row names the version it was measured with and the test suite that asserts it.
- Container test suites (`tests_containers/`): a harness that installs tethered into the official Python Docker images the way a user does, starts real servers, task queues and client libraries in a container without a network, and checks from the outside, by counting what arrives, that the policy holds in every process. 28 suites besides the harness's own, on Python 3.11 to 3.15; they run in CI on pull requests that touch the package, and weekly against the latest release of every server. Run them with `uv run python tests_containers/run.py`.
- [`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md>): a FastAPI exception handler, and a section on what each server and task queue needs for the policy to be enforced in every worker, written from the container suites: the launcher pattern, uvicorn and FastAPI, gunicorn, hypercorn, granian, sanic, aiohttp, daphne and Twisted, tornado, uWSGI, waitress, mod\_wsgi, Flask and Django, Celery, RQ, Dramatiq, Huey and arq, process pools and the fork server, and `InterpreterPoolExecutor`. The FastAPI handler also catches a refusal that arrives inside an `ExceptionGroup`: `httpx.AsyncClient` delivers a refused connection (a port rule, or a URL with a raw IP address) that way, and neither `except tethered.EgressBlocked` nor a handler registered for `EgressBlocked` alone catches it. The connection is blocked either way; `except* tethered.EgressBlocked` catches both shapes.
- The container suites cover `scope()` under real servers: a scope around one request under gunicorn's sync, gthread and gevent workers and under uvicorn on the standard loop, and around one Celery task in the prefork, threads, solo, gevent and eventlet pools. The scope narrows the policy for that request or task only, also while other requests run in the same process on other threads, tasks or greenlets, and a Python child launched inside it inherits it. Two limits are now measured and stated in [`docs/COMPATIBILITY.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COMPATIBILITY.md#scopes>): a thread started inside a scope and `loop.run_in_executor` run without it; and a name lookup that asyncio or gevent makes in a pool thread is not checked against the scope, so the DNS query leaves although the connection is refused.

### Changed

- The message of `EgressBlocked` writes an IPv6 address with a port the way an allow rule does, `[2001:db8::1]:443`, where it was `2001:db8::1:443`, which did not say where the address ends. The attributes and the log lines are unchanged.
- `activate()` and `deactivate()` are all-or-nothing and serialized across threads and across the interpreters of a process: when either raises, the previous policy, the lock and the policy published to child processes are exactly as they were.
- The C extension keeps its state per interpreter and loads in interpreters that have their own GIL (Python 3.12+), where it could not be imported before. It still does not declare free-threading support.
- The `activate()` warning about a missing `tethered.pth` now says what it means: no directory this interpreter's `site.py` processes holds the file, so Python children will not inherit the policy and `sys.executable` launches are treated as external.
- Performance: `with tethered.scope(...)` enter + exit costs about 1 µs (was about 0.5 µs). `activate(locked=True)` takes longer (still under a millisecond) because it generates a larger integrity snapshot; verifying the snapshot on each event is faster than before. Under a locked policy, an `open`, `os.remove` or `os.chmod` of a file that is not tethered's costs about 0.4 µs on Linux and 0.5 µs on Windows (0.6 µs and 1.4 µs in 0.5.3), and an `os.rename` 0.4 µs and 0.7 µs (0.9 µs and 2.6 µs).
- The security model (the threat model, what tethered does not protect against, what locked mode does and does not do) moved from `SECURITY.md` to [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md>). `SECURITY.md` keeps how to report a vulnerability, a summary of the model and links to its sections.
- The README is cut to the pitch, the quick start and the limits, and PyPI shows a description of its own (`PYPI.md`), short and without images. What the README no longer holds has moved: the full allow list syntax to [`docs/API.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/API.md#allow-list-syntax>), the overhead measurements to [`docs/ARCHITECTURE.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/ARCHITECTURE.md#performance>), and the table of examples to [`examples/README.md`](<https://github.com/shcherbak-ai/tethered/blob/main/examples/README.md>). The header image of the READMEs up to 0.5.3 is gone from the repository, so the PyPI pages of those releases show no header.

### Fixed

- `EgressBlocked` and `SubprocessBlocked` can be pickled and copied with all their attributes. A blocked call in a `ProcessPoolExecutor` or `multiprocessing.Pool` worker now reaches the parent as `EgressBlocked`; before, the pool broke (`BrokenProcessPool`) or the result never arrived.
- `EgressBlocked` and `SubprocessBlocked` can be rebuilt from their message alone, `tethered.EgressBlocked(*exc.args)`, which is what a transport does that keeps the class and `args` of an exception without pickling it. With Celery's default JSON result serializer, `result.get()` for a refused task raised a bare `Exception` in a producer that had tethered loaded, so `except tethered.EgressBlocked` did not match; it now raises `tethered.EgressBlocked`, with `host`, `port`, `resolved_from`, `scope_label` and `scope_hint` read back out of the message. The constructors are otherwise as they were: a call that leaves out a field is still a `TypeError`.
- A forked child no longer hangs behind one of tethered's locks. `os.fork()` copies one thread, and a lock that another thread held at that moment stayed locked in the child for ever: the child's next lookup, connection or `activate()` did not return. tethered now resets its locks in the child, as `logging` and `threading` do with theirs.
- The warning and the `SubprocessBlocked` for a launch of this interpreter that is treated as external say why: `tethered: external subprocess launch detected: subprocess.Popen(/usr/local/bin/python) — -S skips site.py and the tethered.pth bootstrap with it: the child holds no policy`. Before, the line named the interpreter tethered runs in and nothing else. The other reasons are an `argv[0]` that is not `sys.executable`, interpreter options that cannot be parsed, `-s` / `-I` with tethered in the per-user site-packages, and no `tethered.pth` in a processed site directory.
- A rule that begins with a character set (`[ab].example.com:443`) is a hostname pattern. Before, every rule starting with `[` was read as a bracketed IP address, which dropped the port and the rest of the pattern.
- A trailing FQDN dot in front of a port (`api.example.com.:443`, `*.example.com.:443`) is ignored, as it already was without a port. Such rules never matched before.
- `scope()` used on its own under asyncio no longer blocks the hosts it allows. `loop.getaddrinfo` runs in an executor thread that does not carry the scope, so the lookup was not recorded and the connection was judged as a bare IP.
- Two allowed hostnames that share an address (CDNs, load balancers) no longer block each other. tethered remembers up to 8 hostnames per address instead of the last one, and a connection is allowed if any of them, or the raw IP, is allowed. Addresses are matched in canonical form, so a dual-stack connect to `::ffff:a.b.c.d` finds the name recorded for `a.b.c.d`.
- `socket.gethostbyname()` followed by a connect to the returned address now works for an allowed host, as the same sequence through `getaddrinfo` did.
- Calling `activate()` with the policy that is already active no longer drops the addresses recorded for hostnames. Every `activate()` used to empty that map, so a thread that had just looked a name up and was about to connect was refused a host the policy allows whenever another thread activated the same policy again. In a test with four threads connecting to an allowed host while a fifth re-activated, 1,493 to 2,872 of 6,000 connections were refused; now none is. The map is still dropped when the policy changes, and by `deactivate()`.
- A hostname-only scope combined with an IP-only scope no longer blocks a connection that each of them allows.
- Under a locked policy, a `subprocess` launch whose explicit environment lacks `_TETHERED_CHILD_POLICY` is given the variable instead of being refused, for a Python child and for an external program alike. An environment built from a list of its own is what ordinary code passes, CPython included: on Linux `ctypes.util.find_library()` runs `ldconfig` with one, so under a locked policy `find_library()` raised `SubprocessBlocked`, and with it `import trio` (hence anyio on trio, and httpx wherever trio is installed), `import asyncssh` and psycopg 3 in its Python implementation. The lock still keeps its promise that no Python starts without the policy, by adding the variable. An environment that carries a different policy, an invalid value or two spellings of the variable is refused as before, and so is `os.posix_spawn` / `os.spawn*` with an environment that lacks the variable, which cannot be completed. `external_subprocess_policy="block"` still refuses the `ldconfig` launch like any external program: import such libraries before `activate()` there.
- An `os.exec*` of the same interpreter with an explicit environment that lacks the policy variable is carried out with the variable added, with or without a lock, and logged under a lock. Before, a locked policy refused it. Whoever calls `os.exec*` has usually just forked, and the refusal left a second copy of the parent running the parent's code: gunicorn's binary upgrade (`SIGUSR2`) under `--preload` with a locked policy stopped the workers and removed the unix socket of the server it was to replace. The upgrade now completes with no `pre_exec` hook.
- A scope reaches a Python child that is started with `os.exec*`. Before, a scope travelled to a child only through `subprocess.Popen`, so under gevent, whose `Popen` forks and calls `os.exec*` from Python, a child launched inside `with tethered.scope(...)` held the policy of the process and not the scope: in gunicorn's gevent workers and in Celery's gevent pool it reached what the scope refuses, and with no `activate()` under the scope it was not restricted at all. tethered's hook now carries such an exec out itself with the scope in the environment, as it already did to add a missing policy variable. A hand-written `os.fork()` and `os.exec*` inside a scope is covered in the same way. `os.spawn*`, `os.posix_spawn` and `os.system` still carry no scope.
- [`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md#django--fastapi-middleware>): the Django middleware it printed did not work. Django turns a view's exception into a 500 response before `get_response()` returns, so a `try` / `except tethered.EgressBlocked` around it never saw the refusal: the connection was blocked, but the request ended as a 500 and the middleware logged nothing. The recipe now uses `process_exception()`. If you copied the earlier middleware, replace it.
- `with tethered.scope():` inside a generator no longer removes the caller's scope or leaves its own behind when scopes exit out of order. A scope exited in a context that never entered it logs a warning instead of raising `ValueError`.
- tethered no longer resolves a hostname that a scope has just refused: the connect-time fallback search only considers names whose lookup every enforcing layer let through. It also skips that search when the address is already allowed by IP (loopback, an IP or CIDR rule), where it could cost up to 30 DNS lookups per connect.
- Interpreter options of a Python launch are read the way CPython reads them. A script argument `-S`, a joined `-cCODE` and `-Wignore::SyntaxWarning` are no longer mistaken for the `-S` flag and treated as external.
- Inside a scope, a child launched with an explicit `env=` that is not a `dict` (`UserDict`, `MappingProxyType`) receives a copy of that mapping. Before, it received the parent's environment instead.
- On Windows, log messages and `SubprocessBlocked.description` for an external launch name the program instead of `subprocess.Popen(None)`.
- `_normalize_host` re-strips whitespace hidden behind a trailing dot (`"host\r."`).
- Under a locked policy, a file named `tethered.pth` in another directory can be removed, renamed and have its mode changed relative to that directory's descriptor while the working directory is the one that holds the hook. Before, such a call was taken for the hook and refused. On POSIX it is the call `shutil.rmtree()` makes for a tree that holds such a file.
- `scope()` overlap warnings judge each rule the way the policy parses it. A scope rule identical to a rule of the global policy no longer draws "has no overlap with global policy" when it starts with or contains a character set (`[ab].example.com:443`), is an unbracketed IPv6 address whose last group is numeric (`2001:db8::1`, `::1`), or is a wildcard with a trailing dot. Also no longer reported as broader: a port-specific rule inside an any-port rule of the same pattern or of a containing network, a wildcard inside a `*`-suffix wildcard (`*.s3.amazonaws.com` under `*.amazonaws.com`), and `localhost` or a loopback address without a port when localhost is allowed. A hostname rule containing `/` is now reported like any other hostname rule; it used to be skipped. A warning is logged only when the overlap is certain. Where it cannot be told from the rule text, nothing is reported; such rules used to be reported as never matching or as broader, sometimes wrongly. That covers two wildcards of which the global one is not `*` + text (`*.example.com` under `*.example.*`, `[ab].example.com` next to `??.example.com`), a wildcard over address text next to a network rule (`10.0.0.*` under `10.0.0.0/24`, or the reverse), and a wildcard or range that may reach localhost while localhost is allowed (`local*`, `127.0.0.0/8`).

### Security

The security fixes that can stop a setup that ran on 0.5.3 are under Breaking changes: uvloop and pycares, the launches that now count as external, a child that cannot establish a locked or fail-closed policy, interpreters outside a lock, a lost hook under a lock, and an audit hook that refuses tethered's. The fixes below also refuse things 0.5.3 let through.

- Socket addresses are checked in every form CPython accepts. `bytes` / `bytearray` hosts and ports given through `__index__` (`numpy.int64` and the like) used to skip the policy entirely, including under `fail_closed` and `locked`. An address on an IP-family socket that tethered cannot read is now refused. `sendmsg()` called without an address is not such a case and passes, as it always did: it is a write on a connected socket, whose destination was checked when it connected, and from Python 3.12 asyncio writes that way.
- A log-only layer no longer switches off an enforcing one. An outer `scope(log_only=True)` used to disable an inner enforcing scope, and a log-only global denial used to skip the scope checks for the lookup.
- Under uWSGI the uwsgi binary is no longer taken for a Python interpreter. There `sys.executable` is the program that embeds Python, and tethered counted a launch of it as a Python child that inherits the policy: `external_subprocess_policy="block"` did not refuse `[sys.executable, "file.ini"]`, and uWSGI ran a command from that file which reached a forbidden destination under a locked policy, with nothing logged. Where Python is embedded and `sys.executable` is not named like an interpreter, every launch now counts as external (warned about, refused under `"block"`), and `activate()` logs a warning that says so and names uWSGI's `--py-sys-executable`. With that option, and under every other server, nothing changes.
- A Python child launched with an explicit `env=` that lacks `_TETHERED_CHILD_POLICY`, or after the variable was cleaned out of `os.environ`, now receives the policy: tethered establishes the payload in the launch environment. Before, such a child ran with no policy and neither `"warn"` nor `"block"` noticed. Locked mode, which refused these launches, now gives them the variable as well where it can (see Fixed), and still refuses an environment that carries a different policy. This holds for the launches tethered sees: `multiprocessing` launches on POSIX before Python 3.14 raise no audit event.
- `-S` is detected when it is quoted inside a Windows command line, when `executable=` is passed on Windows, and after `-X -c`; these forms used to get through `external_subprocess_policy="block"`.
- An editable or `--target` install no longer counts as having the bootstrap. Before, the mere existence of a `tethered.pth` next to the package made tethered trust `sys.executable` children that never ran it.
- `multiprocessing` / `ProcessPoolExecutor` launches are seen by tethered on Windows and, from Python 3.14, on POSIX: `external_subprocess_policy`, the locked-mode payload check and the repair of a cleaned environment now apply to them. Workers started inside a virtual environment by third-party copies of the Windows spawner (joblib's loky, billiard) are recognized as children of `sys.executable`. On POSIX with Python 3.11–3.13 these launches raise no audit event and remain out of reach.
- Locked mode, launch environments: the payload check reads an explicit environment of any mapping type (a `UserDict` without the variable used to pass unnoticed, and the child ran with no policy; it is now given the variable, see Fixed), compares type-exactly (`"log_only": 0` used to pass and leave the child with no policy), refuses more than one spelling of the variable name (a case-variant duplicate won on Windows), and detects changes made through `os.putenv()` / `os.unsetenv()`. The earlier claim that libc `setenv` / `unsetenv` called through `ctypes` is caught was wrong: `os.environ` does not show such a change. The next entry says what is checked now.
- Locked mode, process environment: a launch that inherits the environment is checked against the environment of the process itself, not only against `os.environ`. `os.environ` is a copy each interpreter makes when it starts, so it shows neither a change made from another interpreter of the process nor one made below Python (`ctypes` calling libc `setenv` / `unsetenv`); 0.5.3 saw neither. Such a launch is now refused with `SubprocessBlocked` when the process no longer carries the locked policy. The check is made at the launches tethered sees and does not stop native code from starting a process itself. The interpreters of a process also take turns at `activate()` and `deactivate()`, so that no interpreter's write of the variable is under way while another one publishes a policy and locks it: a call waits while another interpreter is in the middle of one. When interpreters with their own GILs race to take the first lock of a process, the variable carries the winner's policy, and each of the others gets `TetheredLocked` before it has written anything. A call made from inside an `activate()` or `deactivate()` that the same thread is running in another interpreter raises `RuntimeError` instead of waiting. The bootstrap of a new interpreter waits in the same way, so an interpreter created while a locked policy is being replaced joins the new policy.
- Locked mode, integrity: the snapshot now covers every module-level name (runtime flags and counters aside), class attribute and function of `tethered._core` and `tethered._policy` instead of sixteen hand-picked handlers, so rebinding a helper no longer disables enforcement silently. It is also verified on the guarded `tethered.pth` and policy-variable events, not only on socket and launch events. Allow rules are immutable tuples of strings and integers and can no longer be edited in place.
- Locked mode, token: the lock token is held only by the C extension and can no longer be read back from tethered's module state.
- Locked mode, interpreters outside the lock (Breaking changes says which those are): `activate()` and `deactivate()` raise `TetheredLocked` there whatever policy and token are passed, an `activate()` with the identical policy included, because such an interpreter has no locked policy of its own to compare with. The message says what the situation is and what avoids it: a policy is locked by another interpreter of the process, this interpreter is outside that lock and can neither change nor release the policy, and the lock has to be taken before the other interpreters of the process are created. It does not ask for the lock token, which only the interpreter that took the lock can pass. The connections, lookups and launches refused in such an interpreter are not logged.
- Locked mode, joined interpreters: a join is accepted only while an interpreter starts, and only once; code that is already running in an interpreter cannot join. Tampering inside a joined interpreter is detected there and refuses that interpreter everything guarded, while the lock holder and the other interpreters are unaffected. When the lock holder replaces the locked policy with a different one, the interpreters that had joined are outside the lock for good and new ones join the new policy; replacing it with an identical policy keeps them joined. When the lock is released, a formerly joined interpreter keeps enforcing its copy from Python and may deactivate it. If the interpreter that holds the lock is destroyed, the process stays locked: no other interpreter, existing or created later, takes the lock over, joined interpreters keep working, new ones still join, and nothing can release it. A joined interpreter is protected like a locked child process, which means that its startup files are trusted; [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md>) states the rules and the limits.
- Locked mode: `os.truncate()` of `tethered.pth` is refused. The earlier statement that CPython raises no audit event for it was wrong.
- Locked mode: `shutil.copy2()` onto `tethered.pth` is refused on Windows. From Python 3.12 it replaces its destination through `_winapi.CopyFile2`, which raises no `open` event, so it got past the guard and could empty the hook.
- Locked mode: creating a `tethered.start` file beside `tethered.pth` is refused (write-mode `open`, a rename or copy onto the name, `os.link`, `os.symlink`). From Python 3.15, `site.py` ignores the `import` line of a `.pth` file when a `.start` file of the same name sits beside it (PEP 829), so an empty file there stopped Python children from auto-activating. `os.mknod` raises no audit event and is not caught.
- Locked mode: `tethered.pth` and `tethered.start` are recognised by their name and their directory, not by the text of the path. Before, a call that named the directory in another way got past the guard: through a symlink or a junction, by a Windows short name or with a `\\?\` prefix, as `tethered.pth::$DATA`, or relative to a directory descriptor (`dir_fd`). Ordinary tools did that without trying. `pip` names files by their resolved path, so `pip uninstall tethered` run from a locked process removed the hook from an environment that is reached through a symlink or a short name; and on POSIX `shutil.rmtree()` removes files relative to a descriptor, so it deleted the hook although the documentation said it was refused. Still not matched: another name for the file itself (a hard link, a symlink to the file), and `os.open(..., dir_fd=...)`, whose audit event does not carry the descriptor. What to do: install, upgrade and remove tethered from outside the processes that hold a locked policy. pip stops halfway when it is refused; [`docs/COMPATIBILITY.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COMPATIBILITY.md#installing-upgrading-and-removing-tethered>) says what it leaves and how to repair it.
- Locked mode: `tethered.pth` is guarded as a file, wherever a `tethered.pth` that is this file lies. uv installs by hard-linking out of its cache, so in an environment uv made the hook is also a `tethered.pth` in the cache directory, and a write to that one emptied the hook under a lock: Python children then started with no policy. A write-mode `open`, `os.truncate`, `os.chmod` and a copy onto such a file are now refused like the same operation on the hook. Reading it, removing it and clearing the cache are not refused.
- Locked mode: the hook cannot be given another name. `os.link(hook, "x")` followed by `open("x", "w")` emptied the hook under a lock, in any environment: the link was only looked at as a way of creating `tethered.start`, and the write named nothing tethered knows. `os.link` and `os.symlink` with the hook as their source are now refused, and so is renaming another `tethered.pth` that is the same file. Still open, and stated in [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md#locked-mode--what-it-does-and-does-not-do>): a name of another spelling that was made before the policy was locked, `/proc/self/fd/N` on Linux, and `os.open(..., dir_fd=...)`. A hook that is emptied on one of those routes is caught by the next entry.
- A `tethered.pth` counts as the bootstrap only while it holds tethered's own line. tethered used to know its hook by its name and its place. Another distribution that ships a top-level `tethered.pth` replaces tethered's when it is installed, without a word from pip or uv, and tethered took that file for its bootstrap: no warning, and a Python child of a locked, fail-closed process under `external_subprocess_policy="block"` was launched with no policy. `activate()` now logs a warning (`tethered: ... is not the file tethered installs under that name ...`) and treats `sys.executable` launches as external. The file is also read again at each launch of a Python child that would inherit, so a hook that is emptied, removed or replaced while the process runs (by an installer, by another program, on a route the locked-mode guard does not see) makes the next such launch an external one, with the reason in the log line and in `SubprocessBlocked.description`: `... — tethered.pth no longer holds tethered's bootstrap line: the child holds no policy`. Before, the path was found once and believed from then on. Under a locked policy that launch is refused outright (see Breaking changes).
- A `tethered.start` file beside `tethered.pth` is found even where its creation could not be refused. From Python 3.15 such a file switches the hook off (PEP 829). Locked mode refuses creating one by the calls that raise an audit event; a file made with `os.mknod`, one that was there before the policy, and any such file under a policy that is not locked went unnoticed, and Python children started with no policy while tethered took them for covered. `activate()` now logs a warning (`tethered: ... is switched off by the tethered.start file beside it ...`) and treats `sys.executable` launches as external, and a file that appears later is met at the next launch of a Python child like a hook that was emptied.
- Locked mode: tethered's own DNS lookups call the resolver captured when the policy was locked, and a connection attempted from inside such a lookup is refused. A replaced `_socket.getaddrinfo` could previously connect from there.
- [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md>) now states what locked mode checks and what it does not stop. It no longer says that the remaining bypasses require `ctypes`: Python code written against tethered can still defeat locked mode, for example by replacing the standard-library functions the policy calls or by writing into the IP-to-hostname map. Newly documented limitations: the Windows `ProactorEventLoop`, hostnames resolved by `connect()` before the hook sees them, `socket.getnameinfo`, connections that are already open, and `.pth` files written into a site directory, which later child processes and new interpreters can run before tethered's bootstrap. The claim that the design is safe on free-threaded Python has been withdrawn; the C extension does not declare free-threading support.
- uvloop: under asyncio the policy is enforced on the standard event loop only. In 0.5.3 a process that ran on uvloop, which uvicorn uses by default when it is installed, had a policy that was applied to nothing that went through the event loop: `httpx.AsyncClient`, asyncio streams and datagram endpoints reached forbidden hosts with no exception and no log line, `locked=True` and `fail_closed=True` included, and launches through `asyncio.create_subprocess_exec` / `create_subprocess_shell` got neither `external_subprocess_policy` nor the locked-mode environment check nor the active scope. tethered now refuses to run in that arrangement (the uvloop entry under Breaking changes). What remains is documented in [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md#what-tethered-does-not-protect-against>): a `log_only` policy and a `scope()` without a policy run next to uvloop and see nothing of its loop; an interpreter that is outside a lock can still load uvloop; and uvicorn serves an application without a policy when a hand-written ASGI lifespan handler raises tethered's refusal. The README, `docs/SUBPROCESS.md` and `docs/ARCHITECTURE.md` said that tethered works with FastAPI, httpx and aiohttp and that `asyncio.create_subprocess_exec` is supported, without saying on which event loop. They now say it.
- Documented limit, libraries that connect in C: a client library that resolves names and connects inside a C library of its own raises no audit event and is neither blocked nor reported, under a locked policy as well. The earlier one-line statement about "C extensions with raw socket calls" did not say how ordinary this is. Measured: pycurl, curl\_cffi, tornado's `curl_httpclient`, psycopg2, mysqlclient, the C extension of mysql-connector-python, pymssql, pyodbc with FreeTDS, grpcio, confluent-kafka, pyzmq and `awscrt.http` reach every forbidden destination; psycopg 3 has the name checked and the address not. [`docs/COMPATIBILITY.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COMPATIBILITY.md#client-libraries>) has the table and names the pure-Python alternatives, which are enforced. What to do: use a driver that goes through Python's `socket` module, or control such a library's traffic below Python.
- Documented limit, libraries that resolve names themselves: dnspython, eventlet's default greendns, gevent with `GEVENT_RESOLVER=ares`, niquests with a resolver of its own, and c-ares when it was loaded before the policy. Nothing forbidden is reached, but a host the allow list names is refused by its address, and the name of any host is still sent to the name server. What to do: use the system resolver (`EVENTLET_NO_GREENDNS=yes`, gevent's default resolver), or allow the address.
- Documented limits of other runtimes: rloop, granian's event loop, sends a datagram on an unconnected endpoint below Python, where it is not seen. Twisted reports a refused connection as its own `TimeoutError` after the connection timeout and never completes a request whose lookup was refused; nothing forbidden is reached. Under gevent's default resolver a refused lookup now and then never returns, because tethered logs the refusal from one of gevent's resolver threads; with the `tethered` logger at `ERROR` this was not seen in 180 processes.
- Documented limit, unlocked policies in several interpreters of one process: each interpreter enforces its own policy on its own connections, but `_TETHERED_CHILD_POLICY` is one variable for the process, so Python child processes, and interpreters created later, get the policy of whichever interpreter activated last. What to do: give applications with different policies different processes, or lock one policy for the process.
- Documented limit, CPython's internal test modules: one of the ways around locked mode that [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md#locked-mode--what-it-does-and-does-not-do>) lists needs access to the interpreter configuration, which CPython's internal test modules (`_testinternalcapi`, `_testcapi`) offer to Python code. They are installed almost everywhere: in the official `python` Docker images (regular, slim and alpine), with the `python3` package of Debian, Ubuntu and Alpine, in the distroless and Chainguard Python images, the AWS Lambda Python image and miniforge, and compiled into uv-managed Pythons. [The same page](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md#cpythons-internal-test-modules>) lists where the modules were found and where they were not, and what was checked: deleting them from the official images leaves locked mode working.
- Documented limits, application servers and interpreter pools, stated in [`docs/security.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md>) with the configurations that work in [`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md#servers-and-interpreter-pools>): two mod\_wsgi application groups in one process race for the lock under concurrent first requests, and the one whose interpreter already existed gets no network (use one application per process, or take the lock in the main interpreter at process start with a script of its own); mod\_wsgi needs `python-home`, not `python-path`, for a virtual environment; under uWSGI `sys.executable` is the uwsgi binary, so a Python child counts as external unless `--py-sys-executable` names the interpreter; a locked policy survives whatever re-executes the server (a uWSGI reload, gunicorn's `SIGUSR2` upgrade with `--preload`, Django's autoreloader), so changing it takes a stop and a start; and an `InterpreterPoolExecutor` whose workers started partly before the lock has workers of both kinds.

## [0.5.3](<https://github.com/shcherbak-ai/tethered/compare/v0.5.2...v0.5.3>) — 2026-08-19

### Fixed

- `TetheredLocked` crash in auto-activated child processes whose application startup code calls `activate(locked=True)` at import time (e.g. Django settings). The `tethered.pth` bootstrap re-engages the parent's locked policy before user code runs, so the app's own `activate()` call in a spawn-mode child hit the locked-replacement check and crashed. `activate()` over a locked policy is now an idempotent no-op when the requested policy's canonical payload is identical to the active one; `on_blocked` and `lock_token` from the no-op call are ignored (adopting a caller-supplied token would let untrusted code unlock the policy), and a differing policy still raises `TetheredLocked` — now with an `activate()`-specific message.

## [0.5.2](<https://github.com/shcherbak-ai/tethered/compare/v0.5.1...v0.5.2>) — 2026-08-19

### Added

- Standalone overhead microbenchmark ([`benchmarks/overhead.py`](<https://github.com/shcherbak-ai/tethered/blob/main/benchmarks/overhead.py>)) measuring per-event cost across deactivated / activated / activated+scope / activated+locked, and Hypothesis property tests for `AllowPolicy`.

### Fixed

- False "substitution attempt" `SubprocessBlocked` in locked mode under process managers (honcho, supervisord-style runners). The canonical `_TETHERED_CHILD_POLICY` payload serialized exact-host rules from frozensets, whose iteration order follows per-process hash randomization (`PYTHONHASHSEED`) — so a spawn-mode child rebuilding its canonical payload ordered the same entries differently than the parent that serialized it. Any launch passing an env dict captured in another process (e.g. a manager pickling `os.environ` snapshots to runner children) then failed the payload-integrity check and was blocked. The `allow` list is now sorted, making the canonical payload deterministic across interpreters. Workaround on affected versions (0.5.0–0.5.1): set `PYTHONHASHSEED` to a fixed value for all processes.
- `_normalize_host` re-strips whitespace after IPv6 scope-ID removal — splitting on the `%` separator could leave trailing whitespace that the initial strip missed.

## [0.5.1](<https://github.com/shcherbak-ai/tethered/compare/v0.5.0...v0.5.1>) — 2026-05-01

### Added

- `hint=` parameter on `tethered.scope()`. Library authors (and app developers using `scope()` to tighten request handlers, jobs, or third-party calls) can attach a one-line egress contract — typically the hosts the scope needs plus a docs URL — that surfaces whenever any `EgressBlocked` fires while the scope is on the stack. The hint is configuration metadata on the scope: dormant in normal operation, emerging only when something is actually blocked. The hint travels with the scope through subprocess auto-propagation alongside `label=`, so a child blocked by an inherited scope sees the same guidance. See [`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md#surface-actionable-guidance-via-hint>).
- `EgressBlocked.scope_hint` attribute. When a hinted `scope()` is on the stack at block-time, `EgressBlocked` carries the deepest hinted scope's hint and the default message gains a trailing `Hint: <text>` line. The deepest-hinted-scope rule applies regardless of which layer produced the block — your own scope, an inner scope, the host's global `activate()` policy, or an outer scope set by the host or a higher-level library — so a host that has tethered configured but forgot to allowlist your endpoints still sees actionable guidance. Outer hints fall through when the inner scope is unhinted.

## [0.5.0](<https://github.com/shcherbak-ai/tethered/compare/v0.4.0...v0.5.0>) — 2026-04-28

### Added

- `label=` parameter on `tethered.scope()`. Library authors can pass a human-readable label (e.g. `label="WeatherClient.get_forecast"`) that surfaces in log messages and as `EgressBlocked.scope_label` when the scope blocks. Defaults to the auto-derived `"scope(<first 3 allow rules>)"` summary, so existing call sites are unchanged. See [`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md#for-package-authors>).
- `EgressBlocked.scope_label` attribute. When a `scope()` (rather than the global policy) blocks the call, `EgressBlocked` carries the scope's label and the message gains a trailing `(blocked by <label>)` suffix. `None` for global-policy blocks. Helps libraries that wrap `EgressBlocked` in their own exception type attribute the block to the right scope when scopes are nested.
- Scope-aware subprocess propagation. A child process launched from inside `with tethered.scope(allow=[...])` inherits the parent's effective policy at the launch site (global ∩ scopes), not just the at-rest global. Works without `activate()` — a library can use `scope()` alone for self-defense and the narrowing extends through subprocess boundaries. Race-free across threads, no monkey-patching, no opt-in helper required. Limitations: scope cannot propagate through `os.system` / `os.exec*` / `os.startfile` (no env channel to inject), nor through `multiprocessing.Pool` / `ProcessPoolExecutor` in spawn mode (these bypass the `subprocess.Popen` audit event); for multiprocessing scope propagation, use fork mode on Linux.
- Subprocess auto-propagation. Python child processes (multiprocessing pools, `ProcessPoolExecutor`, gunicorn/uvicorn workers, `subprocess.run([sys.executable, ...])`) automatically inherit the parent's tethered policy via a `tethered.pth` site-packages bootstrap. Inherited fields: `allow`, `allow_localhost`, `log_only`, `fail_closed`, `external_subprocess_policy`, and `locked`. Locked-mode children get a fresh per-process `lock_token`. If `locked=True` is requested but the C extension is unavailable in the child, the child logs a warning and falls back to non-locked enforcement.
- `external_subprocess_policy` for parent-side control of *external* subprocess launches. Three values: `"warn"` (default — log every external launch for supply-chain visibility), `"allow"` (silent — for workloads that legitimately shell out frequently), `"block"` (refuse with `SubprocessBlocked`). Applies only to launches that auto-inherit cannot reach: non-Python executables, different Python interpreters, or `sys.executable` launched with `-S` (the only flag that disables `site.py` and the `.pth` bootstrap; `-I` and `-E` keep auto-inherit working). Regular `sys.executable` launches are auto-inheriting and skip this policy entirely. Intercepts `subprocess.Popen`, `os.system`, `os.exec*`, `os.posix_spawn`, `os.spawn*`, and `os.startfile`.
- Locked-mode payload-integrity check on subprocess launches. Tethered verifies that each launch carries the parent's canonical `_TETHERED_CHILD_POLICY` payload byte-for-byte. Catches env stripping (`env={}`), corrupted payloads, payload substitution in explicit `env={...}`, and mutation of `os.environ` itself — Python-level (`os.environ.pop` / `__setitem__`) or via `ctypes` calling libc `setenv`/`unsetenv`.
- Locked-mode `tethered.pth` filesystem-tamper check. Refuses Python-level deletion, rename, write-mode `open`, and `chmod` of the cached `tethered.pth` path while locked, defeating attempts to disable the `.pth` bootstrap from inside the same process (covers `os.unlink`, `pathlib.Path.unlink`, `shutil.rmtree`, `os.replace`, `shutil.copy`, `pathlib.Path.write_*`, etc.).
- `tethered.SubprocessBlocked` exception raised when a subprocess launch is blocked by `external_subprocess_policy="block"` or by the locked-mode payload-integrity / FS tamper checks.
- C guardian integrity checks now cover subprocess audit events in locked mode.
- Runnable examples for the new subprocess features: [`examples/11_subprocess_control.py`](<https://github.com/shcherbak-ai/tethered/blob/main/examples/11_subprocess_control.py>) (`external_subprocess_policy` warn/allow/block) and [`examples/12_scope_subprocess.py`](<https://github.com/shcherbak-ai/tethered/blob/main/examples/12_scope_subprocess.py>) (scope-aware propagation to child processes).
- Documentation reorganized. README is now focused on the pitch + quick start + pointers. Detailed API reference moved to [`docs/API.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/API.md>), subprocess deep-dive to [`docs/SUBPROCESS.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/SUBPROCESS.md>), audit-hook architecture to [`docs/ARCHITECTURE.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/ARCHITECTURE.md>), and framework integration patterns to [`docs/COOKBOOK.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/COOKBOOK.md>). The threat model and bypass enumeration moved to [`SECURITY.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md>).

### Fixed

- Self-introspection of the local hostname no longer raises `EgressBlocked`. `socket.getfqdn()`, `socket.gethostbyaddr(socket.gethostname())`, `socket.gethostbyname(socket.gethostname())`, and `socket.getaddrinfo(socket.gethostname(), …)` all consult the local resolver / `/etc/hosts` / NSS to retrieve the canonical name string without making any network connection — tethered now captures `socket.gethostname()` at `activate()` time and exempts these DNS-introspection paths from the policy check when the lookup target equals it. Affects any caller that introspects its own machine identity: `smtplib`'s `HELO`/`EHLO` greeting, `email.utils.make_msgid()`, `logging.handlers.SMTPHandler` (and downstream users like Django's `AdminEmailHandler`), `paramiko`/`fabric` host-identification paths, etc. The most visible production failure mode was the Django email-on-error cascade (any logged 4xx escalated into a 500), but the fix is general. Connect-time enforcement is unchanged — `connect((gethostname(), port))` to a non-loopback IP still requires an explicit allow rule. Locked-mode tamper of the captured hostname is detected by the C guardian.
- DNS-divergence false-blocks under gevent / load-balanced hostnames. Tethered's audit-time `getaddrinfo` and CPython's own `getaddrinfo` are independent DNS queries; for services with short TTLs and high IP-rotation rates (Microsoft Entra ID, M365, large CDNs), the two queries can disagree and leave the connect-time IP unmapped, producing a spurious `EgressBlocked`. At connect time, when an IP isn't in the IP→hostname map, tethered re-resolves up to 30 most-recently-allowed hostnames (LRU order) and checks if any maps to the connecting IP; on hit it allows the connect and enriches the map. Hot path (mapped IPs) is zero-cost; cost only on the miss path. Works in locked mode — the connect-time fallback is an authorized caller of the C guardian's resolver and is integrity-snapshotted, so it can't be replaced with a no-op without tripping tamper detection.

### Security

- `-S` site-bypass detection decodes bytes / PathLike argv elements via `os.fsdecode`, mirroring what CPython actually sees at `execve` time. Without this, a malicious dep on POSIX could pass `[sys.executable, b"-S", b"-c", payload]` to `subprocess.Popen` and have the bytes flag survive while a string-only check missed it, letting the child run with `site.py` disabled (no auto-activation) under `external_subprocess_policy="block"`. Closes the bypass for opportunistic attackers; deliberate attackers with `ctypes` access can still bypass tethered entirely (existing universal-bypass caveat — see [`SECURITY.md`](<https://github.com/shcherbak-ai/tethered/blob/main/docs/security.md>)).

### Changed

- C guardian's per-event ContextVar consistency check now uses `PyContextVar_Get` (direct C API) instead of `PyObject_CallMethod` — removes Python-level method dispatch from the audit-event hot path.
- C guardian caller-verification on `_guardian.resolve()` now fails closed when `PyEval_GetFrame()` returns NULL (previously skipped silently). Closes a narrow defense-in-depth gap for non-Python C-API callers.

## [0.4.0](<https://github.com/shcherbak-ai/tethered/compare/v0.3.3...v0.4.0>) — 2026-03-29

### Added

- C extension (`_guardian.c`) for tamper-resistant locked mode. Snapshots the identity of every critical Python object at activation time and verifies integrity on every socket event. Detects config replacement, method monkey-patching, frozen field mutation via `object.__setattr__`, and bytecode swapping. On tamper detection, blocks ALL network access (fail-closed) and writes a tamper alert to stderr via `os.write(fd 2)`.
- The C extension is now required — installation fails without a C compiler (pre-built wheels include the compiled extension for all platforms).
- `cibuildwheel` CI job builds platform-specific wheels with the compiled C extension for Linux, macOS, and Windows across Python 3.10–3.14.
- Publish workflow split into a separate GitHub Actions workflow, triggered via `workflow_run` after CI succeeds.
- CodeQL now scans both Python and C/C++ code.
- `cppcheck` static analysis for C code via Docker-based pre-commit hook.

### Changed

- Build backend switched from `hatchling` to `setuptools` for native C extension support.

## [0.3.3](<https://github.com/shcherbak-ai/tethered/compare/v0.3.2...v0.3.3>) — 2026-03-22

### Fixed

- `lock_token` now rejects internable types (`str`, `int`, `float`, `bytes`, `bool`) when `locked=True`. CPython interns these types, so separate literals can share identity and defeat the lock. Use `object()` or a custom instance instead.

### Changed

- Moved `_reset_state()` logic from production code to test fixtures.

## [0.3.2](<https://github.com/shcherbak-ai/tethered/compare/v0.2.0...v0.3.2>) — 2026-03-22

### Added

- Context-local `scope()` API for narrowing allowed destinations within a block of code. Usable as both a context manager (`with tethered.scope(allow=[...]):`) and a decorator (`@tethered.scope(allow=[...])`). Supports `log_only`, `fail_closed`, `allow_localhost`, and `on_blocked` options. Scopes use intersection semantics — they can only narrow the global policy, never widen it. Safe for concurrent use across threads and async tasks.
- Input validation on `activate()` and `scope()` parameters (type checks for `allow`, `on_blocked`, `locked`, `log_only`, `fail_closed`, `allow_localhost`).
- Runnable examples in `examples/`.

## [0.2.0](<https://github.com/shcherbak-ai/tethered/compare/v0.1.4...v0.2.0>) — 2026-03-15

### Added

- Unicode NFC normalization and fullwidth dot normalization for hostnames and allow rules.
- Hostname validation rejects control characters, null bytes, and invisible Unicode.
- `gethostbyaddr` reverse-DNS lookups are now subject to policy enforcement.

### Changed

- `activate(locked=True)` now requires `lock_token` (raises `ValueError` if omitted).
- `activate()` over a locked policy now requires the correct `lock_token` (raises `TetheredLocked`).
- `getaddrinfo` hook forwards all caller arguments (family, socktype, proto, flags) to C-level resolver.
- Locked mode documentation clarified to better reflect the security model.

### Fixed

- IP-to-hostname map now refreshes existing entries on re-resolution.
- Config and IP map updates are now atomic under nested locks.

### Security

- Documented shared-IP/CDN cache mapping as a known limitation in the security model.
- Documented localhost relay risk with default `allow_localhost=True` in the security model.
