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.
pipkeeps 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 notlog_onlyis active,import uvloopraisesImportError; a server that takes uvloop only when the import works (uvicorn, gunicorn'sUvicornWorker) 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()raisesRuntimeError, 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. Alog_onlypolicy and ascope()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 plainuvicorn main:appfalls back and works, while--workers,--reload, older uvicorn releases, hypercorn's uvloop worker, thesaniccommand 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=asynciofor theuvicorncommand,loop="asyncio"inuvicorn.run()), or callactivate()in a launcher script before the server is imported, or uninstall uvloop (docs/COOKBOOK.mdhas 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 notlog_onlyis active,import pycaresandimport aiodnsnow raiseImportError, 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 beforeactivate()is left alone, andactivate()is not refused because of it. What to do: nothing for aiohttp, as long asactivate()comes before aiohttp is imported; where it cannot (python -m aiohttp.web, gunicorn's aiohttp worker class), passaiohttp.TCPConnector(resolver=aiohttp.ThreadedResolver()); and code that needs pycares or aiodns itself has to import it beforeactivate()and allow the addresses it connects to.
Allow rules¶
- Breaking: malformed allow rules raise
ValueErroratactivate()/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]:abcused 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 asfe80::*(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 notethered.pthis in a directorysite.pyprocesses (an editable install, orpip install --target+PYTHONPATH), or when the file there is not tethered's own or, from Python 3.15, has atethered.startfile beside it (both under Security); on POSIX, a launch whoseargv[0]is notsys.executable(["python", ...]withexecutable=sys.executable); a launch whose interpreter options cannot be parsed;-s/-Ilaunches when tethered is installed in the per-user site-packages; and a Python launch throughos.spawn*/os.posix_spawnwith an explicit environment that lacks the policy variable (anos.exec*with such an environment is carried out with the variable added, see Fixed). Under uWSGI started without--py-sys-executable, wheresys.executableis the uwsgi binary and not an interpreter, every launch counts as external (see Security).multiprocessingworkers started aftermultiprocessing.set_executable()named another interpreter, and direct_winapi.CreateProcesscalls, are now subject to the policy as well (Windows;multiprocessingon 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_policythen 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
lockedorfail_closedset 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 forlocked: 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.pthbootstrap: 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.InterpreterPoolExecutorworkers join the same way. Every other interpreter gets no connections, lookups or process launches and cannot changetethered.pth,tethered.startor 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 withtethered.deactivate(), and by nothing once the variable had been removed. A lockedlog_onlypolicy 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.pthno 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 raisesSubprocessBlocked, whateverexternal_subprocess_policysays. 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/Nandos.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_tokennow also rejectscomplex,tupleandfrozensetvalues and the singletonsEllipsisandNotImplemented(TypeError), in addition tostr,int,float,bytesandbool. A locked policy that has failed its integrity check can be neither replaced nor deactivated, even with the correct token:activate()anddeactivate()raiseTetheredLockeduntil the original objects are restored or the process is restarted. What to do: passobject(), 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 raisedEgressBlocked. The same check now also refuses the guardedtethered.pthoperations andos.putenv/os.unsetenvof the policy variable. What to do: catchSubprocessBlocked, orRuntimeError(the base class of both exceptions), around process launches.
activate() and scope()¶
- Breaking:
activate()andscope()raiseRuntimeErrorwhen another audit hook in the process refuses tethered's hook.sys.addaudithookdrops 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 callingactivate()or entering a scope before the other hook is added, or have that hook let tethered's through. - Breaking:
activate()raisesValueErrorand 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 loggedblocked. Underlog_only=Truethe call goes ahead, and a line such astethered: blocked connect to evil.test:443read, to whoever looked at the log later, like a connection that was stopped. It is nowtethered: 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 matchestethered: blockedfor a log-only policy has to matchtethered: would block.
Added¶
- Python 3.15 support: the test matrix runs on 3.15 and wheels are built for it. The
tethered.pthbootstrap keeps working there; PEP 829 deprecatesimportlines in.pthfiles 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 as10.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 asasyncio.SelectorEventLoop. The gap is not new; it is now reported at run time and documented indocs/security.md. - A one-time warning when uvloop is loaded next to a
log_onlypolicy or ascope()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 theforkserverstart 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. Callactivate()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'ssocketmodule (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 atllms.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 withuv 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, andInterpreterPoolExecutor. The FastAPI handler also catches a refusal that arrives inside anExceptionGroup:httpx.AsyncClientdelivers a refused connection (a port rule, or a URL with a raw IP address) that way, and neitherexcept tethered.EgressBlockednor a handler registered forEgressBlockedalone catches it. The connection is blocked either way;except* tethered.EgressBlockedcatches 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 indocs/COMPATIBILITY.md: a thread started inside a scope andloop.run_in_executorrun 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
EgressBlockedwrites an IPv6 address with a port the way an allow rule does,[2001:db8::1]:443, where it was2001:db8::1:443, which did not say where the address ends. The attributes and the log lines are unchanged. activate()anddeactivate()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 missingtethered.pthnow says what it means: no directory this interpreter'ssite.pyprocesses holds the file, so Python children will not inherit the policy andsys.executablelaunches 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, anopen,os.removeoros.chmodof 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 anos.rename0.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.mdtodocs/security.md.SECURITY.mdkeeps 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 todocs/API.md, the overhead measurements todocs/ARCHITECTURE.md, and the table of examples toexamples/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¶
EgressBlockedandSubprocessBlockedcan be pickled and copied with all their attributes. A blocked call in aProcessPoolExecutorormultiprocessing.Poolworker now reaches the parent asEgressBlocked; before, the pool broke (BrokenProcessPool) or the result never arrived.EgressBlockedandSubprocessBlockedcan be rebuilt from their message alone,tethered.EgressBlocked(*exc.args), which is what a transport does that keeps the class andargsof an exception without pickling it. With Celery's default JSON result serializer,result.get()for a refused task raised a bareExceptionin a producer that had tethered loaded, soexcept tethered.EgressBlockeddid not match; it now raisestethered.EgressBlocked, withhost,port,resolved_from,scope_labelandscope_hintread back out of the message. The constructors are otherwise as they were: a call that leaves out a field is still aTypeError.- 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 oractivate()did not return. tethered now resets its locks in the child, asloggingandthreadingdo with theirs. - The warning and the
SubprocessBlockedfor 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 anargv[0]that is notsys.executable, interpreter options that cannot be parsed,-s/-Iwith tethered in the per-user site-packages, and notethered.pthin 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.getaddrinforuns 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.dfinds the name recorded fora.b.c.d. socket.gethostbyname()followed by a connect to the returned address now works for an allowed host, as the same sequence throughgetaddrinfodid.- Calling
activate()with the policy that is already active no longer drops the addresses recorded for hostnames. Everyactivate()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 bydeactivate(). - 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
subprocesslaunch whose explicit environment lacks_TETHERED_CHILD_POLICYis 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 Linuxctypes.util.find_library()runsldconfigwith one, so under a locked policyfind_library()raisedSubprocessBlocked, and with itimport trio(hence anyio on trio, and httpx wherever trio is installed),import asyncsshand 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 isos.posix_spawn/os.spawn*with an environment that lacks the variable, which cannot be completed.external_subprocess_policy="block"still refuses theldconfiglaunch like any external program: import such libraries beforeactivate()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 callsos.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--preloadwith a locked policy stopped the workers and removed the unix socket of the server it was to replace. The upgrade now completes with nopre_exechook. - A scope reaches a Python child that is started with
os.exec*. Before, a scope travelled to a child only throughsubprocess.Popen, so under gevent, whosePopenforks and callsos.exec*from Python, a child launched insidewith 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 noactivate()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-writtenos.fork()andos.exec*inside a scope is covered in the same way.os.spawn*,os.posix_spawnandos.systemstill carry no scope. docs/COOKBOOK.md: the Django middleware it printed did not work. Django turns a view's exception into a 500 response beforeget_response()returns, so atry/except tethered.EgressBlockedaround it never saw the refusal: the connection was blocked, but the request ended as a 500 and the middleware logged nothing. The recipe now usesprocess_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 raisingValueError.- 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-cCODEand-Wignore::SyntaxWarningare no longer mistaken for the-Sflag and treated as external. - Inside a scope, a child launched with an explicit
env=that is not adict(UserDict,MappingProxyType) receives a copy of that mapping. Before, it received the parent's environment instead. - On Windows, log messages and
SubprocessBlocked.descriptionfor an external launch name the program instead ofsubprocess.Popen(None). _normalize_hostre-strips whitespace hidden behind a trailing dot ("host\r.").- Under a locked policy, a file named
tethered.pthin 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 callshutil.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.comunder*.amazonaws.com), andlocalhostor 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.comunder*.example.*,[ab].example.comnext to??.example.com), a wildcard over address text next to a network rule (10.0.0.*under10.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/bytearrayhosts and ports given through__index__(numpy.int64and the like) used to skip the policy entirely, including underfail_closedandlocked. 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.executableis 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 andsys.executableis not named like an interpreter, every launch now counts as external (warned about, refused under"block"), andactivate()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 ofos.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:multiprocessinglaunches on POSIX before Python 3.14 raise no audit event. -Sis detected when it is quoted inside a Windows command line, whenexecutable=is passed on Windows, and after-X -c; these forms used to get throughexternal_subprocess_policy="block".- An editable or
--targetinstall no longer counts as having the bootstrap. Before, the mere existence of atethered.pthnext to the package made tethered trustsys.executablechildren that never ran it. multiprocessing/ProcessPoolExecutorlaunches 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 ofsys.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
UserDictwithout 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": 0used 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 throughos.putenv()/os.unsetenv(). The earlier claim that libcsetenv/unsetenvcalled throughctypesis caught was wrong:os.environdoes 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.environis 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 (ctypescalling libcsetenv/unsetenv); 0.5.3 saw neither. Such a launch is now refused withSubprocessBlockedwhen 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 atactivate()anddeactivate(), 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 getsTetheredLockedbefore it has written anything. A call made from inside anactivate()ordeactivate()that the same thread is running in another interpreter raisesRuntimeErrorinstead 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._coreandtethered._policyinstead of sixteen hand-picked handlers, so rebinding a helper no longer disables enforcement silently. It is also verified on the guardedtethered.pthand 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()anddeactivate()raiseTetheredLockedthere whatever policy and token are passed, anactivate()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.mdstates the rules and the limits. - Locked mode:
os.truncate()oftethered.pthis refused. The earlier statement that CPython raises no audit event for it was wrong. - Locked mode:
shutil.copy2()ontotethered.pthis refused on Windows. From Python 3.12 it replaces its destination through_winapi.CopyFile2, which raises noopenevent, so it got past the guard and could empty the hook. - Locked mode: creating a
tethered.startfile besidetethered.pthis refused (write-modeopen, a rename or copy onto the name,os.link,os.symlink). From Python 3.15,site.pyignores theimportline of a.pthfile when a.startfile of the same name sits beside it (PEP 829), so an empty file there stopped Python children from auto-activating.os.mknodraises no audit event and is not caught. - Locked mode:
tethered.pthandtethered.startare 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, astethered.pth::$DATA, or relative to a directory descriptor (dir_fd). Ordinary tools did that without trying.pipnames files by their resolved path, sopip uninstall tetheredrun from a locked process removed the hook from an environment that is reached through a symlink or a short name; and on POSIXshutil.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), andos.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.mdsays what it leaves and how to repair it. - Locked mode:
tethered.pthis guarded as a file, wherever atethered.pththat is this file lies. uv installs by hard-linking out of its cache, so in an environment uv made the hook is also atethered.pthin the cache directory, and a write to that one emptied the hook under a lock: Python children then started with no policy. A write-modeopen,os.truncate,os.chmodand 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 byopen("x", "w")emptied the hook under a lock, in any environment: the link was only looked at as a way of creatingtethered.start, and the write named nothing tethered knows.os.linkandos.symlinkwith the hook as their source are now refused, and so is renaming anothertethered.pththat is the same file. Still open, and stated indocs/security.md: a name of another spelling that was made before the policy was locked,/proc/self/fd/Non Linux, andos.open(..., dir_fd=...). A hook that is emptied on one of those routes is caught by the next entry. - A
tethered.pthcounts 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-leveltethered.pthreplaces 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 underexternal_subprocess_policy="block"was launched with no policy.activate()now logs a warning (tethered: ... is not the file tethered installs under that name ...) and treatssys.executablelaunches 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 inSubprocessBlocked.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.startfile besidetethered.pthis 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 withos.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 treatssys.executablelaunches 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.getaddrinfocould previously connect from there. docs/security.mdnow states what locked mode checks and what it does not stop. It no longer says that the remaining bypasses requirectypes: 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 WindowsProactorEventLoop, hostnames resolved byconnect()before the hook sees them,socket.getnameinfo, connections that are already open, and.pthfiles 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=Trueandfail_closed=Trueincluded, and launches throughasyncio.create_subprocess_exec/create_subprocess_shellgot neitherexternal_subprocess_policynor 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 indocs/security.md: alog_onlypolicy and ascope()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.mdanddocs/ARCHITECTURE.mdsaid that tethered works with FastAPI, httpx and aiohttp and thatasyncio.create_subprocess_execis 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 andawscrt.httpreach every forbidden destination; psycopg 3 has the name checked and the address not.docs/COMPATIBILITY.mdhas the table and names the pure-Python alternatives, which are enforced. What to do: use a driver that goes through Python'ssocketmodule, 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
TimeoutErrorafter 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 thetetheredlogger atERRORthis 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_POLICYis 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.mdlists 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 officialpythonDocker images (regular, slim and alpine), with thepython3package 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.mdwith the configurations that work indocs/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 needspython-home, notpython-path, for a virtual environment; under uWSGIsys.executableis the uwsgi binary, so a Python child counts as external unless--py-sys-executablenames the interpreter; a locked policy survives whatever re-executes the server (a uWSGI reload, gunicorn'sSIGUSR2upgrade with--preload, Django's autoreloader), so changing it takes a stop and a start; and anInterpreterPoolExecutorwhose workers started partly before the lock has workers of both kinds.
0.5.3 — 2026-08-19¶
Fixed¶
TetheredLockedcrash in auto-activated child processes whose application startup code callsactivate(locked=True)at import time (e.g. Django settings). Thetethered.pthbootstrap re-engages the parent's locked policy before user code runs, so the app's ownactivate()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_blockedandlock_tokenfrom the no-op call are ignored (adopting a caller-supplied token would let untrusted code unlock the policy), and a differing policy still raisesTetheredLocked— now with anactivate()-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 forAllowPolicy.
Fixed¶
- False "substitution attempt"
SubprocessBlockedin locked mode under process managers (honcho, supervisord-style runners). The canonical_TETHERED_CHILD_POLICYpayload 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 picklingos.environsnapshots to runner children) then failed the payload-integrity check and was blocked. Theallowlist is now sorted, making the canonical payload deterministic across interpreters. Workaround on affected versions (0.5.0–0.5.1): setPYTHONHASHSEEDto a fixed value for all processes. _normalize_hostre-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 ontethered.scope(). Library authors (and app developers usingscope()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 anyEgressBlockedfires 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 alongsidelabel=, so a child blocked by an inherited scope sees the same guidance. Seedocs/COOKBOOK.md.EgressBlocked.scope_hintattribute. When a hintedscope()is on the stack at block-time,EgressBlockedcarries the deepest hinted scope's hint and the default message gains a trailingHint: <text>line. The deepest-hinted-scope rule applies regardless of which layer produced the block — your own scope, an inner scope, the host's globalactivate()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 ontethered.scope(). Library authors can pass a human-readable label (e.g.label="WeatherClient.get_forecast") that surfaces in log messages and asEgressBlocked.scope_labelwhen the scope blocks. Defaults to the auto-derived"scope(<first 3 allow rules>)"summary, so existing call sites are unchanged. Seedocs/COOKBOOK.md.EgressBlocked.scope_labelattribute. When ascope()(rather than the global policy) blocks the call,EgressBlockedcarries the scope's label and the message gains a trailing(blocked by <label>)suffix.Nonefor global-policy blocks. Helps libraries that wrapEgressBlockedin 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 withoutactivate()— a library can usescope()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 throughos.system/os.exec*/os.startfile(no env channel to inject), nor throughmultiprocessing.Pool/ProcessPoolExecutorin spawn mode (these bypass thesubprocess.Popenaudit 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 atethered.pthsite-packages bootstrap. Inherited fields:allow,allow_localhost,log_only,fail_closed,external_subprocess_policy, andlocked. Locked-mode children get a fresh per-processlock_token. Iflocked=Trueis requested but the C extension is unavailable in the child, the child logs a warning and falls back to non-locked enforcement. external_subprocess_policyfor 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 withSubprocessBlocked). Applies only to launches that auto-inherit cannot reach: non-Python executables, different Python interpreters, orsys.executablelaunched with-S(the only flag that disablessite.pyand the.pthbootstrap;-Iand-Ekeep auto-inherit working). Regularsys.executablelaunches are auto-inheriting and skip this policy entirely. Interceptssubprocess.Popen,os.system,os.exec*,os.posix_spawn,os.spawn*, andos.startfile.- Locked-mode payload-integrity check on subprocess launches. Tethered verifies that each launch carries the parent's canonical
_TETHERED_CHILD_POLICYpayload byte-for-byte. Catches env stripping (env={}), corrupted payloads, payload substitution in explicitenv={...}, and mutation ofos.environitself — Python-level (os.environ.pop/__setitem__) or viactypescalling libcsetenv/unsetenv. - Locked-mode
tethered.pthfilesystem-tamper check. Refuses Python-level deletion, rename, write-modeopen, andchmodof the cachedtethered.pthpath while locked, defeating attempts to disable the.pthbootstrap from inside the same process (coversos.unlink,pathlib.Path.unlink,shutil.rmtree,os.replace,shutil.copy,pathlib.Path.write_*, etc.). tethered.SubprocessBlockedexception raised when a subprocess launch is blocked byexternal_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_policywarn/allow/block) andexamples/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 todocs/SUBPROCESS.md, audit-hook architecture todocs/ARCHITECTURE.md, and framework integration patterns todocs/COOKBOOK.md. The threat model and bypass enumeration moved toSECURITY.md.
Fixed¶
- Self-introspection of the local hostname no longer raises
EgressBlocked.socket.getfqdn(),socket.gethostbyaddr(socket.gethostname()),socket.gethostbyname(socket.gethostname()), andsocket.getaddrinfo(socket.gethostname(), …)all consult the local resolver //etc/hosts/ NSS to retrieve the canonical name string without making any network connection — tethered now capturessocket.gethostname()atactivate()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'sHELO/EHLOgreeting,email.utils.make_msgid(),logging.handlers.SMTPHandler(and downstream users like Django'sAdminEmailHandler),paramiko/fabrichost-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
getaddrinfoand CPython's owngetaddrinfoare 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 spuriousEgressBlocked. 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¶
-Ssite-bypass detection decodes bytes / PathLike argv elements viaos.fsdecode, mirroring what CPython actually sees atexecvetime. Without this, a malicious dep on POSIX could pass[sys.executable, b"-S", b"-c", payload]tosubprocess.Popenand have the bytes flag survive while a string-only check missed it, letting the child run withsite.pydisabled (no auto-activation) underexternal_subprocess_policy="block". Closes the bypass for opportunistic attackers; deliberate attackers withctypesaccess can still bypass tethered entirely (existing universal-bypass caveat — seeSECURITY.md).
Changed¶
- C guardian's per-event ContextVar consistency check now uses
PyContextVar_Get(direct C API) instead ofPyObject_CallMethod— removes Python-level method dispatch from the audit-event hot path. - C guardian caller-verification on
_guardian.resolve()now fails closed whenPyEval_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 viaobject.__setattr__, and bytecode swapping. On tamper detection, blocks ALL network access (fail-closed) and writes a tamper alert to stderr viaos.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).
cibuildwheelCI 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_runafter CI succeeds. - CodeQL now scans both Python and C/C++ code.
cppcheckstatic analysis for C code via Docker-based pre-commit hook.
Changed¶
- Build backend switched from
hatchlingtosetuptoolsfor native C extension support.
0.3.3 — 2026-03-22¶
Fixed¶
lock_tokennow rejects internable types (str,int,float,bytes,bool) whenlocked=True. CPython interns these types, so separate literals can share identity and defeat the lock. Useobject()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=[...])). Supportslog_only,fail_closed,allow_localhost, andon_blockedoptions. 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()andscope()parameters (type checks forallow,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.
gethostbyaddrreverse-DNS lookups are now subject to policy enforcement.
Changed¶
activate(locked=True)now requireslock_token(raisesValueErrorif omitted).activate()over a locked policy now requires the correctlock_token(raisesTetheredLocked).getaddrinfohook 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=Truein the security model.