API reference¶
tethered.activate()¶
tethered.activate(
*,
allow: list[str],
log_only: bool = False,
fail_closed: bool = False,
allow_localhost: bool = True,
on_blocked: Callable[[str, int | None], None] | None = None,
locked: bool = False,
lock_token: object | None = None,
external_subprocess_policy: str = "warn",
)
| Parameter | Description |
|---|---|
allow |
Required. Allowed destinations — see Allow list syntax. Pass [] to block all non-localhost connections. A malformed rule raises ValueError (see Rule validation). |
log_only |
Log what would be blocked instead of raising EgressBlocked. Default False. |
fail_closed |
Block when the policy check itself errors, instead of failing open. Default False. |
allow_localhost |
Allow loopback addresses (127.0.0.0/8, ::1). Default True. |
on_blocked |
Callback (host, port) -> None invoked on every blocked connection, including in log-only mode. |
locked |
Enable tamper-resistant enforcement via C extension. Replacing or deactivating the policy then requires the lock_token, and a C-level integrity verifier refuses ALL socket operations, process launches, and the guarded tethered.pth / policy-variable operations while it detects tampering with tethered's own code or the active policy. Default False. See Locked mode. |
lock_token |
Opaque token required when locked=True (None counts as missing: ValueError). Must be an instance like object() — singletons and values of immutable built-in types (str, int, float, complex, bytes, bool, tuple, frozenset, Ellipsis, NotImplemented) are rejected with TypeError, because an equal value created elsewhere can be the very same object. Compared by identity (is), not equality. tethered keeps no copy of the token outside the C extension; keep your own reference if you intend to unlock. |
external_subprocess_policy |
Parent-side enforcement for external process launches: programs that are not Python, other Python interpreters, sys.executable launched with -S (which disables site.py), and Python launches that cannot be shown to inherit the policy. Values: "warn" (default — supply-chain visibility), "allow", "block". A Python child of sys.executable auto-inherits the policy and is unaffected by this setting. The subprocess page has the table of what counts as external. |
Replacing the active policy¶
activate() can be called multiple times to replace the active policy — calling it again does not require deactivate() first. If the current policy is locked, the correct lock_token must be provided. Each call creates a completely new policy; no parameters carry over from previous calls. The addresses recorded for hostnames are dropped when the policy changes. A call that puts the same policy in place again (the same rules and settings) keeps them, so that a thread which has just looked a name up is not refused the connection that follows.
The call is all-or-nothing, and calls are serialized, across threads and across the interpreters of the process: a call waits while another thread or interpreter is in the middle of an activate() or deactivate(), and when it gets its turn it sees the result (an interpreter that waited for a lock being taken elsewhere gets TetheredLocked). Whenever it raises, the previously active policy (or none), the lock, and the policy published to child processes are exactly as they were. Two of the cases in which it raises are not about its arguments:
ValueErrorwhen the policy cannot be published to child processes, because its serialized form does not fit in an environment variable. On Windows the limit is 32,767 characters, which is roughly 1,500 short rules.RuntimeErrorwhen the policy would not be applied: when tethered's audit hook cannot be installed because another audit hook in the process refuses new hooks, and when uvloop is loaded (next section).
Event loops and resolvers¶
Under asyncio the policy is enforced on the standard event loop. uvloop does its networking outside Python's socket module and raises no audit event, so nothing that goes through a uvloop event loop could be checked. tethered therefore does not run next to it:
activate()raisesRuntimeErrorwhen uvloop is already loaded in the process, unlesslog_only=True. Nothing is changed. The message beginstethered: activate() refused — uvloop is loaded in this process.and names the ways out; it is also logged at error level before it is raised, because a server can swallow the exception (uvicorn does for a hand-written ASGI lifespan handler).- While a policy that is not log-only is active,
import uvloopraisesImportError(tethered: uvloop was not loaded — a policy is active ...). A server that takes uvloop only when the import works, as uvicorn and its gunicorn worker do, then runs on the standard loop. The refusal is logged once per process as a warning (tethered: refused to load uvloop while a policy is active ...), since such a server catches the error without a word. Nothing is refused or logged when uvloop is not installed. - A log-only policy refuses nothing, neither the import nor the activation. It logs one warning (
tethered: uvloop is loaded in this process. ...) when it finds uvloop loaded, atactivate()or when a socket is created, and reports nothing that goes through the loop. Ascope()with no policy under it behaves the same way.
uvicorn uses uvloop by default when it is installed; name the loop (--loop asyncio) or activate before the server is imported. The cookbook says what each server needs. On Windows the default ProactorEventLoop is enforced only in part; run the loop as asyncio.SelectorEventLoop. Both are described in the security model.
The same import refusal applies to pycares, the c-ares binding under aiodns: while a policy that is not log-only is active, import pycares raises ImportError (tethered: pycares was not loaded — a policy is active ...), and so does import aiodns. c-ares looks names up where tethered sees neither the lookup nor which host an address belongs to, so with it every host allowed by name would be refused. aiohttp falls back to its threaded resolver on its own. The refusal is logged once per process, at info level. A pycares that was imported before activate() is left alone, and activate() is not refused because of it.
What it logs¶
- One info line on success (
tethered: activated with N rules (log_only=...)). - A warning when no
tethered.pthis in a directory this interpreter'ssite.pyprocesses (Python children will not inherit), or when the file that is there is not the one tethered installs (tethered: ... is not the file tethered installs under that name ...): another distribution that ships atethered.pthreplaces tethered's when it is installed. From Python 3.15 also when atethered.startfile lies beside the hook, which switches it off (tethered: ... is switched off by the tethered.start file beside it ...). - One warning per process when multiprocessing's fork server was started before the policy: its workers are copies of the fork server and are not restricted, under a locked policy either (see the security model).
- Later, one warning per process the first time the policy refuses (or, under
log_only, reports) a connection to an IP address that no hostname is known for while the policy has hostname rules (tethered: no hostname is known for the refused address ...). Usually that is an address nothing allows, but it is also what a library that resolves names outside Python'ssocketmodule produces for a host the allow list names.
Rule validation¶
Rules are parsed when activate() or scope() is called, and a malformed rule raises ValueError. That covers:
- an empty rule, and a URL;
- a port that is non-numeric, empty, negative or outside 0–65535 (
host:abc,host:,host:-1,host:70000); - text after the
]of a bracketed IP address ([::1]x), and a network prefix inside the brackets ([2001:db8::/32]); - an invalid network prefix (
[2001:db8::]/200); - a rule with no host (
:443); - a hostname containing
:, which includes wildcards over IPv6 text such asfe80::*(use CIDR); - a
*pattern with no literal character (*,*.*,*[!/]: a[...]set does not count as one).
Three things are accepted that may not be expected:
- One trailing dot on the host part is ignored, so
api.example.com.andapi.example.com.:443are the same rules as without it. A dot after the port (host:443.) is an error. - A rule may begin with a
[seq]character set: brackets are read as an IP literal only when they contain an IP address. - An address-shaped wildcard such as
10.*is a pattern over text, not an address range: it also allows10.attacker.example. tethered logs a warning for such a rule, once per rule per process. Use CIDR for ranges.
tethered.scope()¶
tethered.scope(
*,
allow: list[str],
allow_localhost: bool = True,
log_only: bool = False,
fail_closed: bool = False,
on_blocked: Callable[[str, int | None], None] | None = None,
label: str | None = None,
hint: str | None = None,
)
| Parameter | Description |
|---|---|
allow |
Required. Allowed destinations — see Allow list syntax. A malformed rule raises ValueError, as for activate(). |
allow_localhost |
Allow loopback addresses. Default True. |
log_only |
Log what would be blocked instead of raising. Default False. |
fail_closed |
Block when the policy check itself errors. Default False. |
on_blocked |
Callback (host, port) -> None on every blocked connection. |
label |
Human-readable name for this scope. Appears in log messages and as EgressBlocked.scope_label when this scope produces a block. Library authors should use it to identify call sites — e.g. label="WeatherClient.get_forecast". Defaults to an auto-derived "scope(<first 3 allow rules>)" summary. |
hint |
Actionable guidance attached to any EgressBlocked raised while this scope is the deepest hinted scope on the stack — e.g. hint="mylib needs api.mylib.com:443 — see https://…/security". Surfaces as EgressBlocked.scope_hint and as a trailing Hint: … line on the message. Runs of whitespace, embedded newlines included, collapse to a single space; None, an empty string and whitespace alone mean no hint. The hint travels with the scope into child processes. |
Use as a context manager (with tethered.scope(allow=[...]):) or a decorator (@tethered.scope(allow=[...])). Supports both sync and async functions. Cleanup is automatic — no deactivate() call needed.
A scope is active in the context that entered it (a thread, an asyncio task, a greenlet under gevent or eventlet), from entry until its block exits. It does not follow work into another thread: a thread started inside the block and loop.run_in_executor run without the scope, while asyncio.to_thread copies the context and stays inside it (the compatibility page has what was measured under real servers). Generator and async generator functions cannot be decorated (TypeError). Inside a generator, wrap each I/O call in its own with tethered.scope(...) block and do not yield inside one: a scope that is open across a yield stays active in the consumer's context until the generator resumes. Exiting a scope removes that scope's own entry from the stack, whatever has been entered or exited in between. Exiting it in a context that never entered it removes nothing and logs a warning.
scope() works on its own — no activate() required. When used alone, the scope IS the policy for that code path. Code outside the scope is unaffected. Entering a scope (with, or calling a decorated function) raises RuntimeError when tethered's audit hook cannot be installed, for the same reason as activate(); the body does not run and no scope is left active.
Three caveats apply under asyncio:
- A scope's DNS-time check does not apply to
loop.getaddrinfo, which runssocket.getaddrinfoin an executor thread that does not carry the scope. The connection that follows is still checked against the scope. - Under uvloop a scope is not applied to anything that goes through the event loop, connections included, and nothing is logged for it. An enforcing
activate()policy keeps uvloop out of the process (see Event loops and resolvers); a scope used on its own does not, and only logs a one-time warning when it is entered with uvloop loaded. - On Windows the default
ProactorEventLoopgets no connect-time enforcement at all, for scopes or foractivate(): run the loop asasyncio.SelectorEventLoop(see the security model).
Scopes can only restrict, never widen. If the app also called activate(), the effective policy is the intersection — a connection must be allowed by both.
Package maintainers: Use
scope(), neveractivate(). Your library doesn't own the process — the app does.activate()is a process-wide operation that would interfere with the host application's own policy.scope()is context-local and safe to use from any library.
tethered.deactivate()¶
Disable enforcement. All connections are allowed again. Internal state (IP-to-hostname mappings, callback references) is fully cleared — a subsequent activate() starts fresh.
If activated with locked=True, the matching lock_token must be provided or TetheredLocked is raised. TetheredLocked is also raised, even with the right token, when the locked policy has failed its integrity check (the message says so). The process then has to be restarted, or the original objects restored.
Like activate(), the call is serialized with other activate() / deactivate() calls, in this interpreter and in the others, and leaves everything as it was if it raises. Both raise RuntimeError in the two cases where waiting could never end: when the call is made from inside another interpreter's activate() / deactivate() on the same thread, and when Python is shutting down while another thread holds its turn.
tethered.EgressBlocked¶
Raised when a connection is blocked. Subclass of RuntimeError.
try:
urllib.request.urlopen("https://evil.test")
except tethered.EgressBlocked as e:
print(e.host) # "evil.test"
print(e.port) # 443
print(e.resolved_from) # original hostname if connecting by resolved IP
print(e.scope_label) # "scope(api.mylib.com:443)" if a scope blocked it; None for global blocks
print(e.scope_hint) # actionable guidance from the deepest hinted scope, or None
| Attribute | Type | Meaning |
|---|---|---|
host |
str |
The host (or IP) the call attempted to reach. |
port |
int \| None |
The port, when known (None for getaddrinfo / DNS-only events). |
resolved_from |
str \| None |
When connecting by a resolved IP, the hostname most recently seen resolving to that IP. None for direct hostname connects or when no mapping is known. |
scope_label |
str \| None |
The label of the scope() that blocked the call (e.g. "scope(api.mylib.com:443)"), or None if the block came from the global activate() policy. Useful for libraries that wrap EgressBlocked and want to attribute the block to the right scope when multiple are nested. See COOKBOOK.md#for-package-authors. |
scope_hint |
str \| None |
The hint= set on the deepest hinted scope() on the stack at block-time, or None when no hinted scope was active. The deepest hinted scope wins whichever layer produced the block: an outer hint surfaces when no closer scope set one, and a hint also surfaces when the global activate() policy is what blocks, so a library's guidance reaches the user in blocks the host application's policy produces. |
When scope_label is non-None, the message includes a trailing (blocked by <label>) suffix so the source of the block is visible in tracebacks even without inspecting the attribute. When scope_hint is non-None, the message gains a trailing line of the form Hint: <hint> so the guidance is visible in tracebacks too. An IPv6 address with a port is written the way an allow rule writes it: [2001:db8::1]:443.
A host or address that tethered cannot read (for example a non-ASCII byte string) cannot match any rule and is refused; it surfaces as host="<unreadable …>" with port=None. sendmsg() called without an address is not such a case: it is a write on a connected socket, names no destination, and is let through.
EgressBlocked can be pickled and copied with all its attributes, so a blocked call in a multiprocessing.Pool or ProcessPoolExecutor worker is re-raised in the parent as EgressBlocked. The same holds for a concurrent.futures.InterpreterPoolExecutor worker (Python 3.14+): an EgressBlocked that a task lets out reaches the submitter as EgressBlocked from future.result().
It can also be rebuilt from its message alone, tethered.EgressBlocked(*exc.args), which is how a transport that does not pickle hands an exception on. Celery's JSON result serializer keeps the class name and args, and result.get() in a producer that has tethered loaded raises EgressBlocked. The message is kept as it is and the attributes are read back out of it, as far as it holds them: a resolved_from equal to host was never written into it, and a message from an older tethered, which wrote an IPv6 address with a port as 2001:db8::1:443, gives all of that as host. Any other single argument is a TypeError.
The exception is raised inside the socket call, and the code around that call decides what its caller sees. Most client libraries pass it on unchanged; some wrap it, and a few hide it (Twisted reports a timeout, kafka-python and nats-py an error of their own): the compatibility page lists what was measured. Two cases under asyncio differ from a plain raise. httpx.AsyncClient (and anyio's connect_tcp) deliver a refused connection inside an ExceptionGroup, which except tethered.EgressBlocked does not catch; a refused lookup arrives bare. Use except* tethered.EgressBlocked, or look inside the group. And transport.sendto(data, addr) on an unconnected asyncio datagram transport raises nothing: the datagram is not sent, asyncio closes the transport and passes the EgressBlocked to the protocol's connection_lost(). The cookbook has a handler for both shapes.
EgressBlocked is intentionally a RuntimeError, not an OSError — a policy violation is not a network error and should not be silently caught by HTTP libraries or retry logic. See the cookbook for handling patterns at framework boundaries (Django, FastAPI, Celery, retry decorators).
tethered.SubprocessBlocked¶
Raised when tethered refuses a process launch or, in locked mode, a guarded operation. Subclass of RuntimeError. See the subprocess page for the full behavior. It is raised:
- for an external launch under
external_subprocess_policy="block"; - in locked mode, for a launch whose environment fails the payload-integrity check (a different policy, an invalid value, two spellings of the variable) or cannot be given the payload; for a launch that inherits the environment, that includes a policy variable that was changed from another interpreter or below Python. An explicit environment that merely lacks the variable is not refused where tethered can add it: a
subprocesslaunch gets it, and so does anos.exec*of the same interpreter.os.posix_spawn,os.spawn*and anos.exec*of another program with such an environment are refused; - in locked mode, for a refused operation on
tethered.pth(delete, rename, write-mode open, chmod, truncate, a copy onto it, a hard link or symlink to it) and for creating atethered.startfile beside it; - in locked mode, for the launch of a Python child after
tethered.pthwas emptied, removed or replaced, or was switched off by atethered.startfile beside it (Python 3.15+): the child would start without the locked policy, so the launch is refused whateverexternal_subprocess_policysays; - in locked mode, by the C guardian for a launch, a guarded filesystem operation, or an
os.putenv/os.unsetenvof the policy variable while the integrity check fails; - in locked mode, by the C guardian in an interpreter that neither holds the lock nor has joined it, for every launch and every change to the bootstrap files or the policy variable, and in an interpreter that has joined it for a change to the policy variable (see Locked mode).
For launches it comes out of whichever call raised the audit event: subprocess, os.system, os.exec*, os.spawn*, os.posix_spawn, os.startfile, multiprocessing Process.start() and pool construction (Windows; POSIX from Python 3.14), and direct _winapi.CreateProcess calls (Windows).
| Attribute | Type | Meaning |
|---|---|---|
event |
str |
The audit event that was refused, e.g. "subprocess.Popen", "os.system", "_winapi.CreateProcess", "os.remove". |
description |
str |
What was refused and, where it applies, why — e.g. subprocess.Popen(curl), os.system('curl evil.test'). A subprocess launch is described by its program, without its arguments. |
Like EgressBlocked, it can be pickled and copied with its attributes, and rebuilt from its message alone (tethered.SubprocessBlocked(*exc.args)). description comes back from the message, and event where the description starts with it, as it does for every launch; otherwise event is the empty string.
tethered.TetheredLocked¶
Raised when deactivate() or activate() is called on a locked policy without the correct token — except when activate() re-requests a policy identical to the active one, which is an idempotent no-op (see Locked mode). Also raised, even with the correct token, when the locked policy has failed its integrity check. Subclass of RuntimeError.
Only the interpreter that took the lock can present its token, so in every other interpreter of the process deactivate() raises it while the lock is held, and so does activate() unless it is that no-op. The no-op exists where the interpreter has a locked policy of its own to compare with: in the lock holder and in an interpreter that joined the lock. The message says which situation the caller is in:
| Caller | Message |
|---|---|
The lock holder without its token, or an interpreter that joined the lock: deactivate() |
tethered: deactivate() blocked — policy is locked. Pass the same lock_token to deactivate() to unlock. |
The same callers: activate() with a policy that differs from the locked one |
tethered: activate() blocked — a locked policy is active and the requested policy differs from it. Pass the original lock_token to replace a locked policy. |
An interpreter outside the lock (it holds no locked policy of its own): activate() with any policy, the identical one included, and deactivate() |
tethered: activate() blocked — a policy is locked by another interpreter of this process, and this interpreter is outside that lock: it existed before the lock was taken, or did not join it when it started. It can neither change nor release that policy, whatever token it passes. Take the lock before the other interpreters of the process are created. (deactivate() likewise.) |
| The lock holder with the correct token, when the locked policy has failed its integrity check | tethered: the locked policy has failed its integrity check, so it can be neither replaced nor deactivated — re-activating would take a fresh snapshot of whatever has been put in place. Restart the process. |
A joined interpreter is told to pass the token although it has none: the token never leaves the interpreter that took the lock. The third message is the one an application sees when its interpreter was created before another application of the same process took the lock, for example a second mod_wsgi application group under concurrent first requests (see the cookbook).
Allow list syntax¶
activate() and scope() take the same rules.
| Pattern | Example | Matches |
|---|---|---|
| Exact hostname | "api.stripe.com" |
api.stripe.com only |
| Wildcard subdomain | "*.stripe.com" |
api.stripe.com, dashboard.stripe.com (not stripe.com) |
| Hostname + port | "api.stripe.com:443" |
api.stripe.com on port 443 only |
| IPv4 address | "198.51.100.1" |
That IP only |
| IPv4 CIDR range | "10.0.0.0/8" |
Any IP in 10.x.x.x |
| CIDR + port | "10.0.0.0/8:5432" |
Any IP in 10.x.x.x on port 5432 |
| IPv6 address | "2001:db8::1" or "[2001:db8::1]" |
That IPv6 address |
| IPv6 + port | "[2001:db8::1]:443" |
That IPv6 address on port 443 only |
| IPv6 CIDR | "[2001:db8::]/32" |
Any IP in that IPv6 prefix |
| IPv6 CIDR + port | "[2001:db8::]/32:443" |
Any IP in that IPv6 prefix on port 443 only |
Wildcards use Python's fnmatch syntax. * matches any characters including dots, so *.stripe.com matches both api.stripe.com and a.b.stripe.com. This differs from TLS certificate wildcards. The characters ? (single character) and [seq] (character set) are also supported. Wildcards match text, not address ranges: 10.* also allows 10.attacker.example. Use CIDR for ranges.
Localhost (127.0.0.0/8, ::1) is allowed by default; pass allow_localhost=False to change that. The addresses 0.0.0.0 and :: (INADDR_ANY) are treated as localhost.
Malformed rules raise ValueError at activate() / scope(); Rule validation lists what is rejected. Hostnames containing whitespace, control characters, or invisible Unicode are rejected and never matched by wildcard rules.
Locked mode¶
Tamper-resistant enforcement backed by a C extension:
secret = object()
tethered.activate(allow=["*.stripe.com:443"], locked=True, lock_token=secret)
# Both deactivate() and activate() require the correct token
tethered.deactivate(lock_token=secret)
Calling activate() or deactivate() without the correct lock_token raises TetheredLocked — with one deliberate exception: activate() whose requested policy is identical to the active one (same rules in any order, same flags) is an idempotent no-op. This keeps applications that call activate(locked=True) at import time working inside auto-activated child processes, where the tethered.pth bootstrap has already re-engaged the parent's policy before the app's own startup code runs. Nothing changes on the no-op path — on_blocked and lock_token from the no-op call are ignored, so untrusted code cannot use it to register a callback or swap in a token it controls.
While a policy is locked, the C extension re-checks a snapshot of tethered's own code and of the active policy on every socket event, every process launch, and the guarded tethered.pth / policy-variable operations. If something it recorded has been replaced, those operations are refused (EgressBlocked for socket events, SubprocessBlocked for the others) until the original objects are back, and during that time the policy can be neither replaced nor deactivated, even with the token.
The lock is held once per process, by the interpreter that called activate(locked=True). Every other interpreter of the process (a subinterpreter, a concurrent.futures.InterpreterPoolExecutor worker) is in one of two positions:
- It joined the lock. An interpreter created while the lock is held joins it as it starts, through the
tethered.pthbootstrap. It gets its own locked copy of the policy, checked like the lock holder's: it reaches what the policy allows and getsEgressBlockedfor the rest. It has no token, sodeactivate()raisesTetheredLockedthere andactivate()behaves as it does without the token: a no-op for the identical policy,TetheredLockedfor any other. It cannot change the policy variable either. - It is outside the lock. This is the case for an interpreter that existed before the lock was taken, for one that started without the bootstrap (tethered not installed in site-packages, or a process started with
-S), and for one that joined a policy the lock holder has since replaced with a different one. It can still be created and can compute, but for as long as the lock is held it is refused every connection, lookup and process launch and any change to the bootstrap files or the policy variable (EgressBlocked/SubprocessBlockedwhen tethered is loaded there, otherwiseRuntimeError); these refusals are not logged.activate()anddeactivate()raiseTetheredLockedthere. In an interpreter that never joined, that includes anactivate()with a policy identical to the locked one, and the message says that the interpreter is outside the lock (seeTetheredLocked).
So take the lock before creating subinterpreters or interpreter pools, and install tethered normally (not editable, not --target). An InterpreterPoolExecutor starts its workers on demand, so for a pool this means before the first task is submitted. A server that creates interpreters itself decides the order for you: see the cookbook for mod_wsgi and interpreter pools.
log_only holds in every interpreter: under a locked log-only policy nobody's connections and lookups are refused, while an interpreter outside the lock is still refused launches and the other guarded operations. The security model has the full rules: when a join is accepted, what it does and does not protect, and what happens when the lock is released or its holder is destroyed.
Locked mode is not a sandbox. The security model lists what is checked and what in-process Python code can still do. Two practical points from there: set fail_closed=True together with locked=True, and remember that a token reference you keep (secret above) can also be found by other code in the process — pass lock_token=object() without keeping it if the policy should never be unlocked.
In addition, locked mode hardens the subprocess auto-propagation channel against in-process tampering — payload-integrity checks at every process launch tethered sees, and Python-level FS-tamper protection on the tethered.pth file. See the subprocess page for the details.
Log-only mode¶
Monitor without blocking — useful for rollout or auditing:
tethered.activate(
allow=["*.stripe.com"],
log_only=True,
on_blocked=lambda host, port: print(f"would block: {host}:{port}"),
)
tethered logs to the "tethered" logger via stdlib logging. To see log-only warnings, ensure your application has logging configured (e.g., logging.basicConfig()). What a log-only policy or scope lets through is logged as tethered: would block ... (would block connect to evil.test:443); an enforcing one logs tethered: blocked ... for what it refuses.
log_only is per layer: the global policy and each scope have their own. A log-only layer never switches off an enforcing one. When a log-only global policy denies, the scopes are still checked. Scopes are evaluated outermost first: each denying log-only scope logs and calls its on_blocked, and the first denying scope that enforces raises EgressBlocked.
Intersection semantics — activate() + scope()¶
activate()sets a process-wide ceiling. No code anywhere in the process can reach destinations outside it.scope()creates a temporary restriction within the current context. It can only narrow the effective policy, never widen it.- When both are active, the effective policy is the intersection — a connection must be allowed by both the global policy and every active scope.
- At connect time a destination can be known by several names: the hostnames remembered for its address (up to 8, most recent first) and the raw IP. Each layer — the global policy and each scope — is satisfied if it allows any one of them, and then every layer must be satisfied. Lookups are checked by name only, so a scope that allows only IP addresses still refuses a hostname lookup made inside it.
# Process ceiling: allow Stripe and Twilio
tethered.activate(allow=["*.stripe.com:443", "*.twilio.com:443"])
# Payment endpoint: scope restricts to Stripe only
# (tethered logs a warning that *.sendgrid.com has no overlap with the global policy)
with tethered.scope(allow=["*.stripe.com:443", "*.sendgrid.com:443"]):
# *.stripe.com:443 — allowed (in both global and scope)
# *.sendgrid.com:443 — blocked (not in global policy — scope cannot widen)
# *.twilio.com:443 — blocked (not in scope)
httpx.post("https://api.stripe.com/v1/charges") # works
httpx.post("https://api.sendgrid.com/v3/mail") # raises EgressBlocked
Nested scopes¶
Scopes nest naturally — each level further restricts:
tethered.activate(allow=["*.stripe.com:443", "*.twilio.com:443", "db.internal:5432"])
with tethered.scope(allow=["*.stripe.com:443", "*.twilio.com:443"]):
# db.internal:5432 is excluded by this scope
with tethered.scope(allow=["*.stripe.com:443"]):
# Now only *.stripe.com:443 is allowed
httpx.post("https://api.stripe.com/v1/charges") # works
httpx.post("https://api.twilio.com/v1/messages") # raises EgressBlocked