Skip to content

Changelog

All notable changes to this project will be documented in this file. Each version listed corresponds to a release published on PyPI.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

1.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 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 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).
  • 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 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.
  • 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, 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. 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, with a Markdown version of every page it lists.
  • 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: 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: 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. 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, the overhead measurements to docs/ARCHITECTURE.md, and the table of examples to 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: 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 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 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: 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 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: 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 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 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 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 with the configurations that work in docs/COOKBOOK.md: 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 — 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 — 2026-08-19

Added

  • Standalone overhead microbenchmark (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 — 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.
  • 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 — 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.
  • 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 (external_subprocess_policy warn/allow/block) and 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, subprocess deep-dive to docs/SUBPROCESS.md, audit-hook architecture to docs/ARCHITECTURE.md, and framework integration patterns to docs/COOKBOOK.md. The threat model and bypass enumeration moved to 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).

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 — 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 — 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 — 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 — 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.