# 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](<#locked-mode--what-it-does-and-does-not-do>)).

## 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](<https://tethered.shcherbak.ai/COMPATIBILITY/index.md>).

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](<https://tethered.shcherbak.ai/COMPATIBILITY/#client-libraries>) 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](<https://tethered.shcherbak.ai/COOKBOOK/#servers-and-interpreter-pools>). 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](<https://tethered.shcherbak.ai/SUBPROCESS/index.md>).
- **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](<https://tethered.shcherbak.ai/COOKBOOK/#mod_wsgi>)).
  - 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](<https://tethered.shcherbak.ai/COOKBOOK/#uwsgi>)). 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](<#locked-mode--what-it-does-and-does-not-do>).
- **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](<https://tethered.shcherbak.ai/COMPATIBILITY/#scopes>) 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](<https://tethered.shcherbak.ai/SUBPROCESS/#locked-mode-hardening-of-the-auto-propagation-channel>). 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](<#cpythons-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](<https://tethered.shcherbak.ai/COMPATIBILITY/#installing-upgrading-and-removing-tethered>). 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](<https://tethered.shcherbak.ai/COOKBOOK/#changing-a-locked-policy>).
- 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](<https://tethered.shcherbak.ai/COOKBOOK/#servers-and-interpreter-pools>) 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](<https://tethered.shcherbak.ai/SUBPROCESS/#locked-mode-propagation>).
- **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](<https://tethered.shcherbak.ai/COMPATIBILITY/#client-libraries>) 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](<https://tethered.shcherbak.ai/COOKBOOK/#gunicorn>).
- **`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.

```python
# ✅ 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
```

```python
# ❌ 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](<#locked-mode--what-it-does-and-does-not-do>), and [the cookbook](<https://tethered.shcherbak.ai/COOKBOOK/#servers-and-interpreter-pools>) 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](<https://tethered.shcherbak.ai/COOKBOOK/#the-launcher-pattern>)). The same order matters for multiprocessing's fork server and for process pools: start them after `activate()` (see [above](<#what-tethered-does-not-protect-against>)).

## 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](<https://tethered.shcherbak.ai/COMPATIBILITY/#client-libraries>) 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](<#cpythons-internal-test-modules>)).

## Report a vulnerability

Report a vulnerability privately through [GitHub's private vulnerability reporting](<https://github.com/shcherbak-ai/tethered/security/advisories/new>), not in a public issue. Confirmed vulnerabilities are addressed in a patch release.
