Skip to content

Security model

What tethered defends against, what it does not, and what locked mode adds to that.

Threat model

tethered is a defense-in-depth guardrail, not a security sandbox. It intercepts Python-level socket operations. Code that uses ctypes, cffi, subprocesses, or C extensions with direct syscalls can bypass it. Ordinary libraries do the same without any intent when they resolve names and connect inside a C library of their own: psycopg2, pycurl and grpcio are examples, and tethered sees nothing they do. For the most common case, the uvloop event loop that uvicorn picks whenever it is installed, tethered refuses to run rather than run unenforced. For full process isolation, combine tethered with OS-level controls (containers, seccomp, network namespaces).

tethered's protections are scoped at Python audit events. They defend against:

  • Trusted-but-buggy code that calls socket.connect, requests.get, or similar without thinking about egress (a logging library that phones home for telemetry, a misconfigured dep that fetches metadata from prod).
  • Opportunistic supply-chain compromise — a dep that was hijacked, its attacker doesn't know tethered is in the process and writes the same Python-level network code anyone would write.

They do not make tethered into a sandbox against a deliberately-malicious dependency that knows tethered is installed and writes a few lines of bypass code. The audit-event surface is one trust boundary; ctypes and cffi are another, and they live in the same process with the same permissions. Python code that targets tethered's own internals is a third, and locked mode narrows it without closing it (see Locked mode).

What tethered protects against

Trusted-but-buggy code and supply chain threats: dependencies that use Python's standard socket module (directly or through libraries like requests, urllib3, httpx, aiohttp). tethered prevents these from connecting to destinations not in your allow list. Under asyncio this holds on the standard event loop, where these libraries' async clients use the same socket module. tethered does not run on uvloop (it keeps uvloop from being imported under a policy and refuses to activate over a loaded one), and on Windows it holds only in part on the default ProactorEventLoop; both are described in the next section. Which servers, task queues and client libraries this was measured with, and with which result, is in the compatibility page.

The check covers every address form CPython's socket layer accepts: str, bytes and bytearray hosts, and ports given as any object with __index__. An address on an IP-family socket that tethered cannot read is refused, not allowed. One call carries no address at all and is let through: sendmsg() without one is a write on a connected socket, whose destination was checked when it connected (asyncio writes that way from Python 3.12). A log-only layer (the global policy or a scope) never suppresses an enforcing one: every layer is evaluated, and an enforcing layer that denies still raises.

What tethered does NOT protect against

Native code, and libraries that do not use Python's sockets

  • ctypes / cffi / direct syscalls — the universal bypass. Native code can call libc's connect() (egress), unlink() (delete tethered.pth), setenv()/unsetenv() (mutate _TETHERED_CHILD_POLICY), or execve() (spawn) directly, all of which skip the Python audit-event machinery tethered hooks into. (Python's own os.putenv() / os.unsetenv() raise audit events and are detected; the libc calls raise none. In locked mode a changed _TETHERED_CHILD_POLICY is still noticed afterwards, at the next process launch tethered sees that inherits the environment, because that launch is checked against the environment of the process itself. Native code can just as well start the process itself.) None of tethered's locked-mode hardenings stop a ctypes-armed attacker; they raise the bar for opportunistic attacks but don't close it for deliberate ones.
  • Libraries that resolve names and connect in C. An extension module that calls the C library's getaddrinfo() and connect() itself, or links a client library that does, never passes through Python's socket module. No audit event is raised, so tethered neither blocks nor reports what it does: the forbidden name is looked up, the forbidden address and port are reached, locked=True and fail_closed=True change nothing, and log_only reports nothing. An audit hook cannot close this gap. Measured examples, each reaching every forbidden destination under a locked policy: pycurl and curl_cffi (libcurl), tornado's curl_httpclient, psycopg2 (libpq), mysqlclient, the C extension of mysql-connector-python (its default), pymssql and pyodbc with the FreeTDS driver, grpcio (sync and grpc.aio), confluent-kafka (librdkafka), pyzmq, and awscrt.http. psycopg 3 is enforced in part: it looks the name up through Python, where it is checked, and then hands the address to libpq, so a raw address or a port outside the rule is reached. Pure-Python drivers of the same services are enforced (pg8000 and asyncpg, PyMySQL, mysql-connector-python with use_pure=True, oracledb in thin mode, kafka-python). The compatibility page has the table. That such a module is loaded says nothing about its use: _awscrt is loaded by every botocore import when awscrt is installed, while boto3 goes on connecting through urllib3, which is enforced.
  • Libraries that resolve names themselves and connect through Python. tethered ties an address to a hostname when it sees the lookup (socket.getaddrinfo, socket.gethostbyname). A resolver that does not go through those leaves tethered with a connection to an address it has no name for. This fails in the safe direction for connections and not for names: no forbidden destination is reached, but a host that the allow list names is refused as well (... 203.0.113.7:443 is not in the allow list), and the name of any host, forbidden ones included, is still sent to the name server. Measured: c-ares through pycares / aiodns (what aiohttp resolves with when aiodns is installed), dnspython, eventlet's default greendns, gevent with GEVENT_RESOLVER=ares, and niquests with a resolver of its own. For c-ares the DNS query is sent in C, so it also reaches a name server at a forbidden address; dnspython's query is a socket.sendto and is checked like any datagram. tethered keeps pycares from being imported while a policy that is not log_only is active (ImportError), so that aiohttp falls back to its threaded resolver, which is enforced; a pycares that was imported before activate() is left alone. The first refused connection to an address with no known name logs one hint about this. The remedies are the system resolver (aiohttp: activate() before aiohttp is imported, or resolver=aiohttp.ThreadedResolver(); eventlet: EVENTLET_NO_GREENDNS=yes; gevent: its default resolver) or a rule for the address.

Event loops

  • asyncio on uvloop: refused, with four exceptions. uvloop's event loop does its networking in libuv, below Python's socket module, and raises no audit event for it: on that loop asyncio.open_connection, loop.create_connection(host, port), loop.getaddrinfo, datagram endpoints, clients built on them such as httpx.AsyncClient, and launches through asyncio.create_subprocess_exec / create_subprocess_shell are neither blocked nor reported, whatever the policy. uvicorn runs on uvloop by default whenever it is installed. tethered therefore does not run next to it. While a policy that is not log_only is active, importing uvloop raises ImportError, and a server that takes uvloop only when the import works (uvicorn, its gunicorn worker) runs on the standard loop instead. When uvloop is already loaded, activate() raises RuntimeError and logs the same message as an error: the server does not start. This guards a deployment against silently not being enforced; it is not a barrier to code that is out to get around tethered, which can load any other extension that does its own networking. What remains:

    • A log_only policy refuses nothing, uvloop included. It is activated over a loaded uvloop and reports nothing that goes through the loop, so the list of what would be blocked is empty for every asynchronous call. One warning is logged.
    • A scope() with no activate() under it neither keeps uvloop out nor refuses to be entered. On uvloop it is not applied to what goes through the loop. One warning is logged.
    • An interpreter outside a lock (next section) holds no policy of its own. The lock refuses its sockets but not its imports: it can load uvloop, and a connection on a uvloop loop then reaches a forbidden destination. activate() looks at its own interpreter only, so uvloop loaded in another interpreter does not stop it.
    • A server that swallows the refusal. uvicorn's default --lifespan auto takes an exception from a hand-written ASGI lifespan handler for "lifespan not supported" and serves. An application that calls activate() there, after uvicorn has loaded uvloop, runs on uvloop with no policy; the error that activate() logs is the only trace. FastAPI and Starlette report a failed start-up and the server exits.

    What tethered sees on uvloop in those cases is code that uses Python's sockets itself: blocking socket calls, also through asyncio.to_thread, and loop.sock_connect on a Python socket. Name the standard loop in the server's configuration (uvicorn --loop asyncio), or keep uvloop out of the environment. Which servers fall back, which refuse to start and which need a launcher script is in the cookbook. On the standard loop everything tested is enforced: streams, create_connection, sock_connect, datagram endpoints, httpx and aiohttp.

  • Other event loops that are not asyncio's. rloop (granian's Rust loop) makes its connections, lookups and launches through Python's socket and subprocess modules and is enforced, with one exception: a datagram sent with transport.sendto(data, addr) on an unconnected endpoint goes out below Python and is not seen. trio and Twisted go through the socket module and are enforced.

  • asyncio on the Windows default event loop (ProactorEventLoop). Windows only. That loop connects and sends datagrams through overlapped I/O calls (_overlapped.ConnectEx, _overlapped.WSASendTo) that raise no socket.connect / socket.sendto audit event. On it, raw-IP destinations, port rules and scope() connect checks are not enforced; only the global policy's DNS-time hostname check applies. tethered logs a one-time warning when a socket is created while a Proactor loop is running. Run the loop as asyncio.SelectorEventLoop for full enforcement (asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop) on Python 3.12+). Synchronous code on Windows, and asyncio's standard event loop on other platforms, are not affected.

What the socket events do not cover

  • Lookups for hostnames passed directly to connect() / sendto(). CPython resolves the address before it raises the socket.connect / socket.sendto audit event, so for sock.connect(("host.example", 443)) the DNS lookup happens before tethered sees the call. The connection or datagram itself is still refused. Code that resolves first (socket.getaddrinfo, socket.create_connection, and the HTTP libraries built on them) is checked before the lookup.
  • socket.getnameinfo. Not checked. Its audit event carries the address but not the flags, so a reverse lookup cannot be told apart from numeric formatting (NI_NUMERICHOST), which libraries call on every peer and local address. Reverse lookups through socket.gethostbyaddr are checked.
  • Connections that are already open. tethered checks connection, DNS and datagram events, not reads and writes. A connection opened before activate(), or before entering a narrower scope(), stays usable. That includes a pooled HTTP connection reused inside a request-scoped scope().

Child processes

  • Egress inside non-Python child processes. Python children auto-inherit tethered (via tethered.pth + the _TETHERED_CHILD_POLICY env var), and external_subprocess_policy can monitor or block the launch of any subprocess. But tethered cannot enforce egress rules inside a non-Python executable (curl, ffmpeg, bash, etc.). For hard subprocess isolation, combine with OS-level controls (seccomp, containers). See the subprocess page.
  • Spawning a different Python interpreter (e.g. subprocess.run(["/usr/bin/python3", …])) where tethered isn't installed — the child has no tethered.pth to fire, so it runs uncontrolled. external_subprocess_policy="block" shuts this down.
  • Spawning Python with -S which disables site.py and so skips the auto-activation tethered.pth. -I / -E keep site.py enabled and so are NOT a bypass, with one exception: when tethered is installed in the per-user site-packages (pip install --user), -s and -I skip that directory and the bootstrap with it. Such launches are treated as external, and so are a launch whose interpreter options cannot be parsed and, on POSIX, a launch whose argv[0] is not sys.executable (an interpreter works out which installation or virtual environment it belongs to from argv[0], so started as python it can come up where no tethered.pth is processed). external_subprocess_policy="block" refuses them.
  • Python children when tethered is not installed into site-packages. site.py runs tethered.pth only from a site directory: the interpreter's site-packages or the enabled per-user site-packages. An editable install (-e) and a --target install used through PYTHONPATH have no such file, whether uv or pip makes them. activate() then logs a warning, no Python child auto-inherits, and every sys.executable launch is treated as external. A file of that name that is not tethered's counts as no file: tethered takes a tethered.pth for its bootstrap only while it holds the line tethered installs. Another distribution that ships a top-level tethered.pth replaces tethered's when it is installed, without a word from pip or uv (measured with pip 26.2.1 and uv 0.13.0), and uninstalling that distribution removes the file.
  • Known gaps in the Python-launch classification.
    • A tethered.pth processed only through an explicit site.addsitedir() call is not recognized. The error is on the safe side: the warning above is logged and sys.executable launches are treated as external, so they are warned about or, under "block", refused. mod_wsgi's python-path option adds its directories that way, after the interpreter has started, and leaves sys.executable at the system Python: with tethered installed only in the virtual environment, a Python child launched under the default "warn" then runs without tethered. Give mod_wsgi a virtual environment with python-home instead (see the cookbook).
    • Environment variables in the child's environment that change its site configuration (PYTHONNOUSERSITE, PYTHONUSERBASE, PYTHONHOME) are not inspected.
    • If the tethered package is importable only through PYTHONPATH, a child started with -E or -I runs the .pth line but cannot import tethered, and starts without a policy.
    • A child whose environment is wiped on the way, by a program in between that is not Python (env -i, a process manager configured to do so), starts without the policy too.
    • Where Python is embedded, sys.executable need not be a Python interpreter: under uWSGI it is the uwsgi binary, and a launch of it starts uWSGI. tethered takes an interpreter that is embedded (it has no command line of its own in sys.orig_argv) and whose sys.executable is not named python… or pypy… for such a program: activate() logs a warning, and every launch counts as external, a launch of the real interpreter included, although that child does inherit. Start uWSGI with --py-sys-executable /path/to/python (the cookbook). An embedding program that is itself named like an interpreter is not told apart.
  • External tools deleting tethered.pth (e.g. subprocess.run(["rm", "...tethered.pth"])). The launch fires subprocess.Popen audit, but the deletion happens in the rm process — outside this Python interpreter. external_subprocess_policy="block" shuts this down.
  • multiprocessing spawn / forkserver launches on POSIX with Python 3.11–3.13. They raise no audit event (_posixsubprocess.fork_exec raises one from Python 3.14), so tethered sees neither the launch nor its environment: no external_subprocess_policy, no locked-mode payload check, and no repair of a cleaned os.environ. Workers still inherit the policy while _TETHERED_CHILD_POLICY is intact in the process environment. On Windows, and on POSIX from Python 3.14, these launches are handled.
  • Workers of the forkserver start method, when the fork server is older than the policy. A forkserver worker is a copy of the fork server process, not of the process that asks for it and not a new interpreter. A fork server that was started before activate() holds no policy (or the one before), and so does every worker it forks from then on, also after activate(locked=True): neither the copy of the caller's state that a forked child gets nor the bootstrap that a spawned child runs reaches them, and asking the fork server for a worker goes over a socket and raises no launch event, so tethered can neither refuse it nor complete it. Measured on Python 3.14, where forkserver is the default start method on Linux: workers started after the lock reached a forbidden destination. activate() logs one warning when it finds a fork server already running. Call activate() before the first pool or process is started; a fork server that starts under the policy is an ordinary Python child and passes it on. The same holds for any process that existed before activate(): pool workers started earlier have no policy until they are replaced.
  • The environment and command line of a direct _winapi.CreateProcess call (Windows). The audit event does not carry the environment. tethered reads it for multiprocessing and, inside a virtual environment, for third-party copies of its spawner (joblib's loky, billiard); for any other direct caller the launch is judged as if it inherited the process environment. The event does not deliver a usable command line either (CPython 3.11–3.15 pass that argument incorrectly), so on this route a launch is judged by its executable alone: -S and other interpreter options are not seen. Launches through subprocess are not affected; they are checked from the subprocess.Popen event.

Code in the same process

  • In-process disabling (without locked=True). Code in the same interpreter can call deactivate() or activate() unless locked=True is used.
  • Different unlocked policies in the interpreters of one process. Without a lock each interpreter can have a policy of its own, and each enforces its own on the connections made in it. Child processes and later interpreters are another matter, because _TETHERED_CHILD_POLICY is one variable for the whole process and every activate() writes it. A Python child process gets the policy of whichever interpreter activated last, whichever interpreter launches it, and an interpreter created afterwards starts under that policy even if the code that runs in it never imports tethered. Two mod_wsgi application groups with different unlocked policies in one process are an example. Without a lock a launch is not compared with the environment of the process itself, so nothing reports this. Give each application its own process, or lock one policy for the whole process.
  • In-process tampering with scopes. scope() is best-effort narrowing within the global ceiling — there is no scope(locked=...) equivalent. A compromised dep inside a scoped function can wipe _scopes.set(()), mutate frozen _ScopeConfig fields via object.__setattr__, or monkey-patch _check_scopes — all of which lose the narrowing and fall back to whatever the global policy allows. This is bounded by intersection semantics: scope tampering can never widen egress beyond the global policy, only release the additional narrowing. For a ceiling that takes a token to replace, use activate(locked=True). Scopes inside a locked global remain soft; the global has the protection, and the limits, described under Locked mode.
  • Scope lifetime under generators, executor threads and tasks. A generator suspended inside with scope(): leaves the scope active in the consumer's context between yields. A lookup made in another thread on the scope's behalf is not checked against the scope at DNS time (asyncio's loop.getaddrinfo runs in an executor thread); the connection that follows is checked. A task created inside a with scope(): block keeps the scope after the block exits, but with no activate() policy its later loop.getaddrinfo lookups are no longer recorded, so its connections by hostname are blocked. A scope does not follow work into another thread: a thread started inside the block and loop.run_in_executor run without it (asyncio.to_thread carries it), and so does a lookup in the thread pool of gevent's resolver. In each of these the activate() policy still applies. The compatibility page has what was measured.
  • In-process Python code written against tethered (with locked=True). Locked mode requires the token to replace or deactivate the policy and detects changes to the objects it has recorded. It is not a sandbox: code in the same process that targets tethered can still defeat it, without ctypes. The next section lists what is checked and what is not.

Locked mode — what it does and does not do

activate(locked=True, lock_token=...) requires the C extension and does the following:

  • Token. Replacing or deactivating the policy requires the token object passed at activation, compared by identity. Only the C extension holds a reference to it; tethered keeps no Python-visible copy.
  • Snapshot. At activation the extension records several hundred facts about tethered's own code and the active policy: what every module-level name in tethered._core and tethered._policy is bound to (except the active config, which the extension tracks itself, and lower-case scalar runtime state such as flags and counters); every class attribute of the classes defined there; the code object of every Python function found; that no builtin those modules use is shadowed in their namespaces; that no __getattribute__ / __getattr__ has been added to those classes; and the class and every slot of the active config and policy.
  • When it is checked. The facts are re-checked on every socket audit event, every process-launch event, every open / os.remove / os.rename / os.chmod / os.truncate / os.link / os.symlink / _winapi.CopyFile2 event whose path mentions tethered.pth or tethered.start, and every os.putenv / os.unsetenv of _TETHERED_CHILD_POLICY.
  • On a mismatch. The operation is refused for as long as the mismatch lasts: EgressBlocked for socket events (creating a socket included), SubprocessBlocked for the others. One alert line is written to stderr the first time. Restoring the original objects ends the refusals. While the check fails, the policy can be neither replaced nor deactivated, even with the token (TetheredLocked).
  • tethered's own lookups. The DNS lookups tethered makes to map addresses to hostnames go through the extension, which calls the resolver it captured when the policy was locked. Setting tethered's Python-side reentrancy flag by hand is detected on the next socket event, and a connection or datagram attempted from inside such a lookup is refused.
  • Immutable policy data. Allow rules are immutable tuples of strings and integers, and the child-policy payload is compared against an immutable string, so neither can be edited in place.
  • Propagation channel. Launch environments and tethered.pth are guarded as described in the subprocess page. A launch environment that carries a different policy, an invalid value or two spellings of the variable is refused. One that simply lacks _TETHERED_CHILD_POLICY is what ordinary code passes, so it is given the variable where that is possible (a subprocess launch, and an os.exec* of the same interpreter) and refused where it is not (os.posix_spawn, os.spawn*, an os.exec* of another program). The guard on the file includes path-based os.truncate of tethered.pth; os.ftruncate carries only a file descriptor and cannot be matched, but the open that would yield a writable descriptor for the file is refused. It covers the same file under its name in another directory, which is what an installation by uv leaves in uv's cache (uv installs by hard-linking), and it refuses giving the file a further name (os.link, os.symlink, renaming such a copy). It also includes creating tethered.start beside the hook: from Python 3.15, site.py ignores the import line of tethered.pth when that file exists (PEP 829).
  • The hook's content. A tethered.pth counts as the bootstrap only while it holds the line tethered installs and, from Python 3.15, has no tethered.start file beside it. activate() checks that, and both are checked again at every launch of a Python child that would inherit. Once the hook has been emptied, removed, replaced or switched off, a locked policy refuses such a launch, whatever external_subprocess_policy says: the lock promises that no Python child of this interpreter starts without the policy, and nothing can put the hook back from inside. Without a lock the launch is an external one, warned about or, under "block", refused. This is what answers for the routes the file guard does not see (listed below), for an installer that is run from outside the process, and for another distribution that ships a file of the same name.
  • Other interpreters: joining the lock. The lock is held once per process, by the interpreter that activated it (the lock holder), and only the holder has the token. An interpreter created while the lock is held joins it during its own startup, through the same tethered.pth bootstrap that child processes use. The bootstrap runs when tethered.pth is in a site directory the interpreter processes and _TETHERED_CHILD_POLICY is in the environment of the process. A joined interpreter gets a locked copy of the holder's policy, taken from the C extension's own record of that policy and not from the variable, and a snapshot of its own copy of tethered's code. Its events are checked like the holder's: it reaches what the policy allows and is refused the rest by its own policy, with the ordinary EgressBlocked ("not in the allow list"). Tampering inside it is detected there and refuses that interpreter everything guarded, with an alert line of its own; the holder and the other interpreters are not affected. It holds no token, so it cannot deactivate, replace or weaken the policy (TetheredLocked), and it is refused every os.putenv / os.unsetenv of _TETHERED_CHILD_POLICY, which is also what assigning to or deleting from os.environ does (SubprocessBlocked). A worker of concurrent.futures.InterpreterPoolExecutor that is created under the lock joins it like any other interpreter.
  • Other interpreters: when a join is accepted. The extension cannot tell tethered's own objects from stand-ins that code in an interpreter has prepared, so it goes by time: a join is accepted only while the interpreter starts, and only once. Startup is over when the extension sees that interpreter's import sitecustomize audit event, which site.py raises right after it has processed the .pth files, before any application code runs there. Code that is already running in an interpreter cannot join. This makes a joined interpreter as well protected as a locked child process, and no better: its startup files are trusted. Code that can write a .pth file into a site directory can have it run before the bootstrap, in a child process and in a new interpreter alike, and the snapshot then records what that file left behind. The limits listed below apply in every interpreter.
  • Other interpreters: outside the lock. While the lock is held, the C extension refuses an interpreter that has not joined it every connection, datagram and lookup (loopback and allowed hosts included), every process launch, and any change to tethered.pth / tethered.start or to _TETHERED_CHILD_POLICY. Such an interpreter can still be created and can compute. Outside the lock are:

    • an interpreter that existed before the lock was taken, which includes the main interpreter when a subinterpreter takes the lock, and an interpreter that was still starting while the first lock of the process was being taken;
    • one that started without the bootstrap running, because no tethered.pth is in a site directory it processes (an editable or --target install) or the variable was not in the environment of the process;
    • one whose join failed, which logs one warning and starts (a tethered.pth that is processed only after startup, through site.addsitedir(), ends this way);
    • every other interpreter of a process that runs without site (-S), where the end of startup cannot be recognised and no join is accepted;
    • one that joined a policy that has since been replaced or released (next item).

    The exception raised there is that interpreter's own EgressBlocked / SubprocessBlocked when tethered is loaded in it, and RuntimeError otherwise. The C extension makes these refusals and logs nothing for them. activate() and deactivate() raise TetheredLocked in an interpreter that has no locked policy of its own, also when the policy asked for is identical to the locked one; the message says that the interpreter is outside the lock and that no token will help. A locked log_only policy refuses such an interpreter no connection or lookup, as it refuses the holder none; launches, the bootstrap files and the variable stay refused. A copy of the policy that an interpreter outside the lock holds decides only where the extension lets an event pass: when no lock is held, and for connections and lookups under a log_only lock.

  • Other interpreters: when the lock changes. When the holder replaces the locked policy with a different one, the interpreters that had joined are outside the lock from then on, and stay there: there is no joining twice. Interpreters created afterwards join the new policy. Replacing the policy with an identical one (the same rules, in any order, and the same settings) keeps them joined. When the lock is released, the extension enforces nothing in any interpreter; a formerly joined interpreter keeps enforcing its copy of the policy from Python and may now deactivate it, and it is outside any lock taken later. If the interpreter that holds the lock is destroyed, the process stays locked: joined interpreters keep working, new ones still join, and nothing can replace or release the lock. Under an unlocked policy nothing of this applies: a subinterpreter gets its own unlocked instance.

Locked mode is not a sandbox. It stops accidental and casual disabling and detects the tampering described above. Python code written against tethered can still do the following, without ctypes:

  • Replace builtins or the standard-library functions the policy calls (fnmatch.fnmatchcase, ipaddress.ip_address, json.loads, os.path functions). The snapshot covers tethered's two modules, not the modules they call into.
  • Write into containers whose identity is pinned but whose contents are not: the IP-to-hostname map (an entry naming an allowed host for an address lets a connection to that address through) and the fallback candidate set.
  • Change function defaults or closure cells. Code objects are pinned; defaults and closures are not.
  • Rebind names in tethered/__init__.py or tethered._autoactivate, which are not in the snapshot.
  • Tamper before activation. The snapshot records whatever is in place when activate() runs. In a child process and in an interpreter that joins a lock, that moment is the bootstrap.
  • Write startup files. Only tethered.pth and tethered.start are guarded. Another .pth file written into a site directory is run by every later child process and every new interpreter, and when site.py processes it before tethered.pth it runs before the bootstrap: the child, or the joining interpreter, then records whatever that file put in place. site.py goes through the .pth files of a directory in the order of their names. A distribution that ships a .pth file of its own has the same effect from the moment it is installed, with nothing written under the lock.
  • Start an interpreter that never finishes starting. A join is accepted until the interpreter's import sitecustomize event, and an interpreter that starts without site never raises it. tethered accepts no join in a process that was started with -S. But code that switches site off for new interpreters after the policy is locked, by rewriting the interpreter configuration, gets an interpreter in which code that runs later can still join, with objects of its own making, and is then not restricted. CPython's internal test modules (_testinternalcapi, _testcapi) offer Python code that kind of access, and native code has it anyway. These modules are not a rarity: almost every CPython installation ships them, the official Docker images included (see CPython's internal test modules).
  • Recover the token. Any reference the application keeps (a module global, an attribute, a live frame local) can be found by code in the same process. Only a token nobody keeps, such as lock_token=object() passed inline, cannot be recovered; such a policy can then never be unlocked.

Other limits:

  • With the default fail_closed=False, a policy check that raises allows the connection, so making a standard-library function raise during matching lets the connection through. Set fail_closed=True together with locked=True.
  • Scopes are not protected (see above), in any interpreter.
  • The tethered.pth guard knows the file by its name: in its own directory, however the directory is named in the call (through a symlink or a junction, by a Windows short name, relative to a directory descriptor), and as the same file in another directory. Not matched: a name of another spelling that existed before the policy was locked; on Linux /proc/self/fd/N of a descriptor that was opened for reading, which names the file without a link being made; os.open(..., dir_fd=...), whose audit event does not carry the descriptor; and anything on a file system that reports no file index (there only the text of the path counts). The /proc and dir_fd routes were measured on Linux with Python 3.11 to 3.15: each emptied the hook under a locked, fail-closed policy, and the next launch of a Python child was then refused by the check of the hook's content, under the default external_subprocess_policy as well.
  • The check of the hook's content says that the file is tethered's, not that nothing else runs at startup: a tethered.pth that holds tethered's line is the hook whatever other .pth files lie beside it (see "Write startup files" above). It is made at the launches tethered sees. Without a lock, under the default external_subprocess_policy="warn", a Python child of a process whose hook has been lost is still started, without a policy; the warning is all there is. And the refusal under a lock has a price: when tethered is removed from the environment of a running locked process, or upgraded to a version whose hook differs, that process launches no Python child until it is restarted.
  • The guard refuses a package installer like any other code. pip uninstall tethered, or an upgrade, run from a Python child of a locked process stops halfway and leaves the installation without its metadata; see the compatibility page. Install, upgrade and remove tethered from outside the processes that hold a locked policy.
  • On Python 3.15 and later a tethered.start file beside tethered.pth stops Python children from auto-activating. Locked mode refuses the ways of creating it that raise an audit event. os.mknod (POSIX) raises none. A file that got there all the same, or was there before the policy, is found by the check of the hook's content: activate() warns, and the launch of a Python child is refused under a lock and an external launch without one.
  • A process launch on another thread during an authorized replacement of a locked policy can be refused spuriously.
  • A locked policy survives whatever re-executes the process. The new process image inherits the policy from its environment and has it in place before the application is imported, and it cannot be replaced without the token, which did not survive. A uWSGI reload, gunicorn's binary upgrade (SIGUSR2) under --preload and Django's autoreloader are such re-executions: an application that then asks for a different locked policy gets TetheredLocked, and what the server does with that differs (uWSGI answers every request with a 500, gunicorn's new master does not start, runserver exits). The same policy asked for again is a no-op. To change a locked policy, stop the server and start it; see the cookbook.
  • Under a lock, a launch through os.posix_spawn or os.spawn* with an environment that lacks the policy variable is refused, because that environment cannot be completed. reactor.spawnProcess of Twisted with its default, empty environment is such a launch.
  • An interpreter outside the lock is refused without consulting the allow list, so it cannot reach even an allowed host, and on Windows socket.socketpair() fails there (it connects over loopback). Take the lock before creating subinterpreters or interpreter pools.
  • What an interpreter outside the lock is refused are the audit events the C extension sees: sockets, launches, the bootstrap files and the policy variable. It holds no policy of its own, so nothing keeps it from importing uvloop, and a connection made on a uvloop event loop, or by any library that connects in C, raises no event and is not refused (the uvloop case was measured, under mod_wsgi). "No network" there means no network through Python's socket module.
  • What counts is the moment an interpreter starts, and the application does not always choose that moment. concurrent.futures.InterpreterPoolExecutor starts its workers on demand: an executor object created before the lock joins if its first task is submitted afterwards, and a pool with one worker started before the lock and one after has both kinds, so that tasks of one request can end differently. mod_wsgi creates the interpreter of an application group at that group's first request: when two application groups of one process get their first requests at the same moment, the interpreter of the second already exists when the first takes the lock, and that application is outside the lock, with no network, for as long as the lock is held. The cookbook has the configurations that avoid both.
  • A lock covers the process, not the application that took it. An interpreter that runs other code, such as a second mod_wsgi application group that never imports tethered, joins when it is created under the lock and is restricted by that policy, and gets no network when it existed before.
  • Whether a new interpreter joins depends on its bootstrap. It starts outside the lock when _TETHERED_CHILD_POLICY is no longer in the environment of the process (the holder itself is not refused removing it; launches that would inherit the environment are then refused), and when a startup file imports sitecustomize before tethered.pth is processed, which ends the startup window early. Both fail closed under an enforcing policy: the interpreter gets no network while the lock is held.
  • Destroying the interpreter that holds the lock leaves the process locked for good: the lock can no longer be replaced or released, and no interpreter, including one created afterwards, is treated as its holder.
  • Under a locked log_only policy, an interpreter outside the lock with no tethered copy of its own (the variable was removed from the environment before it was created) is neither refused nor reported, and the socket events of interpreters outside the lock keep passing while the holder is being refused after a failed integrity check.

CPython's internal test modules

The route listed above as "Start an interpreter that never finishes starting" needs a way to rewrite the interpreter configuration from Python code, and CPython's own test suite brings extension modules that reach it: _testinternalcapi and _testcapi. They are not confined to development installations. A survey of Linux x86-64 images in October 2026 found them importable in:

  • the official python:3.X, python:3.X-slim and python:3.X-alpine Docker images (3.11 to 3.15);
  • Debian and Ubuntu with the python3 package (the modules belong to libpython3.X-stdlib), and Alpine with its python3 package;
  • gcr.io/distroless/python3-debian12 and -debian13, cgr.dev/chainguard/python, public.ecr.aws/lambda/python:3.13 and condaforge/miniforge3;
  • Pythons installed by uv (uv python install), where _testinternalcapi is compiled into the interpreter and _testcapi is absent.

They were absent from Debian and Ubuntu with only python3-minimal, which has no ctypes either, and from Fedora's python3, Amazon Linux 2023's python3.13 and UBI 9's python3.12 as long as the matching -test package is not installed; those three do have ctypes.

What can be done about it: where the modules are files, remove them from the image. In the official images, deleting _testinternalcapi*.so and _testcapi*.so from the interpreter's lib-dynload directory left tethered's locked mode working (checked on Python 3.13 and 3.14). In a uv-managed Python there is no file to delete. Two cautions. The survey established where the modules can be imported, not that each of them defeats locked mode in each image; what they offer differs between Python versions. And removing them closes this one route: ctypes and other native code remain, as do the other items in the list above.

Known residual DNS divergence

Tethered's audit-time getaddrinfo and CPython's own getaddrinfo are independent DNS queries. For load-balanced services with short TTLs (Microsoft Entra ID, M365, large CDNs), and especially under gevent's threadpool resolver, the two queries can return different IP sets — leaving the connect-time IP unmapped and producing a spurious block. To repair this without monkey-patching, tethered re-resolves recently-allowed hostnames at connect-time when the IP map misses (_fallback_resolve, bounded to 30 candidates per miss). The same search matches an address obtained through socket.gethostbyname / gethostbyname_ex back to its name: those lookups record a candidate but no mapping. A name becomes a candidate only when its lookup was let through by the global policy and by every enforcing scope that saw it, so tethered does not itself resolve a name a layer has just refused. The search is skipped when every layer already allows the raw IP (loopback, an IP or CIDR rule). Works in locked mode — the C guardian's caller verification accepts _fallback_resolve as a second authorized caller of _c_guardian.resolve, and _fallback_resolve is integrity-snapshotted so replacing it with a no-op is detected.

In rare cases where the hostname rotates between the application's query and the fallback's own query (sub-second IP rotation faster than the OS resolver cache, or extreme gevent threadpool latency), the fallback may also miss the connecting IP and produce a spurious block. Mitigation: preflight known hostnames at startup, allow the upstream's published IP/CIDR ranges, or accept the residual rate as a defense-in-depth trade-off.

Design trade-offs

  • Fail-open by default. If tethered's matching logic raises an unexpected exception, the connection is allowed and a warning is logged. A bug in tethered should not break your application. Use fail_closed=True for stricter environments. The child-process bootstrap follows the same rule: when the inherited policy has locked or fail_closed set and cannot be established in a child, the child refuses to start; otherwise a warning is logged and the child continues without it. An interpreter created inside a locked process is treated differently in one respect: if it cannot join the lock it starts all the same, with a warning, and stays outside the lock, where an enforcing policy leaves it no network. See the subprocess page.
  • A refusal is a RuntimeError, and the code in between decides what the caller sees. EgressBlocked is deliberately not an OSError, so that retry logic written for network errors does not retry it. The other side of that: code that expects only network errors at that point does not always pass it on. Measured: Twisted reports a refused connection as its own TimeoutError when the attempt's timeout is over, and never completes a request whose lookup was refused; pymongo raises ServerSelectionTimeoutError with the refusal only in its text; kafka-python and nats-py raise an error that does not mention tethered; anyio and trio deliver a refused connection inside an ExceptionGroup. In each of these nothing forbidden was reached, and tethered's own warning is in the log. The compatibility page lists what each library does.
  • tethered logs in the thread that made the call. The warning for a refusal is written by the audit hook, in whatever thread raised the event. Under gevent's default resolver that is one of gevent's resolver threads, and with gevent's monkey-patched locks a thread that logs there is now and then never woken: a refused lookup then never returns and its request hangs. Measured with gevent 26.9 on Python 3.13 in about one in ten new processes that refused several lookups at once; an audit hook that only logs and raises, with no tethered, did the same. With the tethered logger set to ERROR it was not seen in 180 processes, at the price of tethered's log line for each refusal. See the cookbook.
  • external_subprocess_policy="block" refuses what libraries launch, too. On Linux ctypes.util.find_library() runs ldconfig and platform.platform() runs uname. Under "block" both raise SubprocessBlocked, which code that expects OSError does not catch: importing trio fails, and celery worker does not start. Import such libraries, and call platform.platform(), before activate().
  • Audit hooks are irremovable. sys.addaudithook has no remove function (by design — PEP 578). deactivate() makes the hook a no-op but cannot unregister it. This is per-process only — no persistent state, no system changes, everything is gone when the process exits.
  • An earlier audit hook can refuse tethered's. sys.addaudithook silently drops a new hook when a hook already in the process raises on the sys.addaudithook event. tethered confirms that its hooks are live, with the private audit events tethered.hook_probe and, in locked mode, tethered._guardian.probe (other audit hooks see them), and activate() and entering a scope() raise RuntimeError instead of running unenforced. In a child process this follows the bootstrap rule above: with locked or fail_closed the child refuses to start, otherwise it logs a warning and runs without the policy.
  • IP-to-hostname mapping is bounded. The LRU cache holds up to 4096 addresses, keyed by canonical address, with up to 8 hostnames per address (most recent first). In long-running processes with many unique DNS lookups, older mappings are evicted. A connection to an evicted IP is checked against IP/CIDR rules only.
  • Direct IP connections skip hostname matching. Connecting to a raw IP without prior DNS resolution means only IP/CIDR rules apply — hostname wildcards won't match. On shared-IP infrastructure (CDNs, cloud hosting), multiple hostnames may resolve to the same IP. A connection is allowed if any hostname remembered for the address, or the raw IP, is allowed, so two allowed names on one address do not block each other. The other side of that: if an allowed hostname shares an IP with a disallowed one, a raw-IP connection to that address will pass hostname policy via the cached mapping. This is inherent to any system that cannot bind a socket to a specific hostname identity.
  • Wildcards match text, not address ranges. An address-shaped wildcard such as 10.* or 192.0.2.* is an fnmatch pattern over the host string: it matches the addresses it looks like and also every hostname that fits the pattern, such as 10.attacker.example, and through the IP map whatever address that name resolves to. tethered logs a warning for such a rule (once per rule per process). Use CIDR notation (10.0.0.0/8) for ranges.
  • Localhost allows local relays. With the default allow_localhost=True, any proxy, tunnel, or forwarding agent listening on 127.0.0.1 or ::1 can relay traffic to external destinations, bypassing the intent of the egress policy. In high-security environments where local relays are a concern, set allow_localhost=False and explicitly allow only the loopback addresses and ports your application needs. On Windows, socket.socketpair() and asyncio's event-loop creation open a loopback connection on an ephemeral port, so with allow_localhost=False either allow 127.0.0.1 on any port or create the event loop before activating.

Supply chain defense — early activation

tethered is most effective when activated before importing untrusted or third-party packages. Any code that runs before activate() executes in an unprotected window — a compromised dependency could make network calls during its import-time initialization.

# ✅ Correct: activate before importing third-party code
import tethered
tethered.activate(allow=["*.stripe.com:443", "db.internal:5432"])

import stripe          # safe — tethered is already active
import compromised_pkg # safe — any network calls are blocked
# ❌ Wrong: third-party code runs before tethered is active
import stripe
import compromised_pkg  # could phone home during import!

import tethered
tethered.activate(allow=["*.stripe.com:443"])  # too late

For maximum protection, combine early activation with locked=True (which also propagates to spawn-mode children with their own per-process tokens, gives the policy variable to a launch environment that lacks it, and refuses one that carries a different policy), fail_closed=True (so that an error inside the policy check blocks instead of allowing) and, if your workload doesn't legitimately launch external programs, external_subprocess_policy="block" to refuse every external launch. Python children of the same interpreter still start under "block"; they inherit the policy instead.

The same order matters for subinterpreters. Take the lock before creating them, concurrent.futures.InterpreterPoolExecutor workers included: an interpreter created under the lock joins it and enforces the policy, while one that already exists when the lock is taken stays outside it and, unless the policy is log_only, gets no network at all for as long as the lock is held. For a pool that means before its first task is submitted, since that is when a worker interpreter starts. Install tethered into site-packages (a regular install, not editable and not --target), because the join is made by the tethered.pth bootstrap. See Locked mode, and the cookbook for servers that create interpreters themselves (mod_wsgi).

On asyncio, early activation does one more thing: it keeps uvloop out of the process, so that a server which would otherwise pick it runs on the standard event loop, where the policy is enforced. An activate() that comes after uvloop was loaded is refused (RuntimeError), and the server does not start. Which of the two a deployment meets depends on the server and on how it is started, so name the loop where the server lets you, or activate in a launcher script before the server is imported (the cookbook). The same order matters for multiprocessing's fork server and for process pools: start them after activate() (see above).

Recommendations

For defense-in-depth, combine tethered with:

  • OS-level sandboxing (containers, seccomp-bpf, network namespaces) for hard isolation.
  • Subprocess restrictions (external_subprocess_policy="block" for hardened environments, plus seccomp filters for non-Python binaries).
  • Import restrictions to prevent ctypes/cffi loading in untrusted code paths.
  • The standard asyncio event loop for asyncio applications, named in the server's configuration (uvicorn --loop asyncio), and no uvloop in the environment where nothing needs it.
  • Client libraries that go through Python's socket module: a pure-Python database driver in place of one that links a C client library, the system resolver in place of c-ares or a DNS client of the library's own. The compatibility page says which is which. For a library that connects in C, only a control below Python applies.
  • Images without CPython's internal test modules, where they are separate files (see CPython's internal test modules).

Report a vulnerability

Report a vulnerability privately through GitHub's private vulnerability reporting, not in a public issue. Confirmed vulnerabilities are addressed in a patch release.