README.md

May 7, 2026 · View on GitHub

tethered

tethered — Runtime network egress control for Python

One function call. Zero dependencies. No infrastructure changes.

PyPI Python PyPI Downloads License: MIT
coverage CI CodeQL security: bandit
Ruff uv ty

tethered is a lightweight, in-process policy check that hooks into Python's own socket layer to enforce your allow list before any packet leaves the machine. Use activate() to set a process-wide ceiling, and scope() to tighten individual code paths — request handlers, background jobs, library calls, AI-generated code. Everything runs locally within your process — works with requests, httpx, aiohttp, Django, Flask, FastAPI, and any library built on Python sockets.

import tethered

tethered.activate(allow=["*.stripe.com:443", "db.internal:5432"])

import urllib.request
urllib.request.urlopen("https://api.stripe.com/v1/charges")  # works — matches *.stripe.com:443
urllib.request.urlopen("https://evil.test/exfil")            # raises tethered.EgressBlocked

Why tethered?

Your code, your dependencies, and AI coding agents all share the same Python process — and any of them can make network calls you didn't intend. A compromised dependency phones home. An AI coding agent writes tests that accidentally call live APIs. An AI-generated function calls an unauthorized endpoint. A misconfigured library hits production instead of staging.

Python has no built-in way to prevent this at runtime. Infrastructure-level controls (firewalls, network policies, proxies) require platform teams, separate services, or admin privileges. None of them give you a single line of Python that says "this process may only talk to these hosts."

tethered fills this gap at the application layer. One function call controls what any code in the process — yours, your dependencies', or AI-generated — can reach over the network. No proxies, no sidecars, no admin privileges. It's complementary to infrastructure controls, not a replacement.

Use cases

Use caseHow tethered helps
🔒Supply chain defenseactivate() locks the process to your known services — a compromised dependency can't phone home.
🔬Scoped isolationscope() restricts a specific code path — a request handler, a background job, a library call — to only the destinations it needs.
🤖AI agent guardrailsCode generated by AI coding agents can't reach unauthorized endpoints — activate() enforces your allow list on any code running in the process.
🧪Test isolationactivate() in your test setup ensures the suite never accidentally hits production services.
📋Least-privilege networkingCombine activate() for the process boundary with scope() for per-function restrictions — declare your network surface like you declare your dependencies.

Install

uv add tethered

Or with pip:

pip install tethered

Requires Python 3.10+. Zero runtime dependencies. Pre-built wheels are available for Linux, macOS, and Windows. Source installs require a C compiler (the package includes a C extension for tamper-resistant locked mode).

Quick start

tethered has two complementary APIs. Both use the same allow list syntax (see below).

Enforce process-wide — activate()

Call activate() as early as possible — before any library makes network connections:

# manage.py, wsgi.py, main.py, or your entrypoint
import tethered
tethered.activate(allow=["*.stripe.com:443", "db.internal:5432"])

# Then import and run your app
from myapp import create_app
app = create_app()

This pattern works the same for Django, Flask, FastAPI, scripts, and AI-assisted workflows — activate tethered before your application and its dependencies start making connections. Existing connections (e.g., connection pools) established before activate() continue to work — tethered intercepts at connect time, not at read/write time.

Use locked=True in production to prevent any code from replacing or disabling your policy. Full parameter list and locked-mode details: docs/API.md.

Restrict a code path — scope()

Use scope() to restrict egress for a specific code path — a request handler, a background job, a library call:

import tethered
import httpx

@tethered.scope(allow=["*.stripe.com:443"])
def charge(amount: int, token: str) -> dict:
    # ... validate input, call helper libraries, log analytics —
    # none of them can reach anything except *.stripe.com:443
    resp = httpx.post("https://api.stripe.com/v1/charges", ...)
    return resp.json()

Also usable as a context manager (with tethered.scope(allow=[...]):). No deactivate() needed — cleanup is automatic. Works with sync and async functions.

scope() works on its own — no activate() required. When the app also called activate(), the effective policy is the intersection — a connection must be allowed by both. Scopes can only narrow, never widen. Full details: docs/API.md.

Package maintainers: Use scope(), never activate(). Your library doesn't own the process — the app does. Pass hint= to attach a one-line egress contract (what hosts your library needs + docs link) to your scope; if a block ever fires — yours or the host's — the hint is readable as exc.scope_hint and appended to the exception message. See docs/COOKBOOK.md.

Subprocess control

Python child processes (multiprocessing.Pool, ProcessPoolExecutor, gunicorn workers, plain subprocess.run([sys.executable, ...])) automatically inherit the parent's tethered policy — including any active scope. Non-Python launches (curl, ffmpeg, different Python interpreters, or sys.executable with -S which disables site.py) are governed by external_subprocess_policy ("warn" default, or "block").

import tethered, subprocess, sys

tethered.activate(allow=["*.stripe.com:443"])
subprocess.run([sys.executable, "-c", "..."])  # child auto-inherits policy

with tethered.scope(allow=["api.stripe.com:443"]):
    subprocess.run([sys.executable, "-c", "..."])  # child also inherits the active scope

Full mechanics, locked-mode hardening, and the auto-vs-external rules: docs/SUBPROCESS.md.

Allow list syntax

PatternExampleMatches
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

Wildcard matching: Uses 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.

Localhost (127.0.0.0/8, ::1) is always allowed by default. The addresses 0.0.0.0 and :: (INADDR_ANY) are also treated as localhost. Malformed hostnames containing whitespace, control characters, or invisible Unicode are rejected and never matched by wildcard rules.

Examples

ExampleDescription
01_basic_activate.pyProcess-wide allow list with activate()
02_scope_context_manager.pyscope() as a context manager
03_scope_decorator.pyscope() as a function decorator
04_global_with_scope.pyGlobal policy + scope — intersection semantics
05_global_with_nested_scopes.pyGlobal policy + nested scopes — progressive restriction
06_locked_mode.pylocked=True — prevent policy tampering
07_log_only.pyMonitor-only mode with on_blocked callback
08_scope_in_threads.pyScoping inside thread pool workers
09_async_scope.pyAsync decorator and context manager
10_package_maintainer.pyLibrary self-restricting with scope()
11_subprocess_control.pyAuto-propagation to Python child processes + parent-side external_subprocess_policy
12_scope_subprocess.pyScope propagating to spawn-mode child processes

Hardened configuration

For threat models that include compromised dependencies — a maintainer's PyPI account hijacked, a malicious release pushed, the malware exfils local secrets to an attacker C&C — combine tethered's strictest settings:

import tethered

_LOCK = object()
tethered.activate(
    allow=["api.stripe.com:443", "db.internal:5432"],   # only what you actually need
    locked=True,                                          # tamper-resistant policy (C-guardian-protected)
    lock_token=_LOCK,                                     # required when locked=True
    external_subprocess_policy="block",                   # if your app doesn't shell out
)

# Only NOW import third-party code — including potentially compromised deps
import stripe

This combination:

  • Auto-propagates the policy to every Python child (multiprocessing workers, subprocess.run, asyncio.create_subprocess_exec) — a multi-stage exfil chain (Layer 0 → spawn → Layer 1 → ...) cannot escape by launching a fresh interpreter.
  • Refuses non-Python launches (curl, wget, bash) — closes the "shell out to a binary that does the egress" bypass.
  • Refuses tampering with the propagation channel — in-process code cannot strip _TETHERED_CHILD_POLICY, delete tethered.pth, or replace the policy with a permissive one without raising SubprocessBlocked.

Activate before importing any third-party code — anything that runs in the unprotected window (e.g., during a malicious package's __init__.py) is unprotected. tethered does not protect against ctypes / cffi raw syscalls; pair with OS-level controls (seccomp, containers, network namespaces) for hard isolation.

Defense-in-depth

tethered is a guardrail at the Python audit-event layer, not a sandbox. Code that uses ctypes, cffi, subprocesses, or C extensions with direct syscalls can bypass it. For hardened environments combine with OS-level controls (containers, seccomp, network namespaces). See SECURITY.md for the full threat model.

Performance

The audit hook runs on every relevant socket event, but per-event cost is small — TCP handshake, TLS, and application logic dominate real-world connect time. The hook fires whenever tethered is engaged, whether you entered via activate() or scope(), so the per-call overheads below apply to both. Measured on CPython 3.13 / localhost / default benchmark settings (3,000 TCP-connect iterations × median-of-3, 5,000 per-event iterations):

OperationOverhead added
socket.connect~25 µs
`socket.getaddrinfo$~120 µ\text{s} (~2 \times ) — \text{tethered} \text{re}-\text{resolves} \text{to} \text{populate} \text{the} \text{IP}→\text{hostname} \text{cache}
\text{Any} \text{non}-\text{network} \text{audit} \text{event}~150 \text{ns} (\text{ambient} \text{tax})
$with tethered.scope(...)` enter + exit~0.5 µs (context-manager cost, independent of activate())

Numbers vary by hardware, OS, and Python version. Reproduce with python benchmarks/overhead.py.

Documentation

  • docs/API.md — full API reference (activate, scope, deactivate, exceptions, locked mode, log-only)
  • docs/SUBPROCESS.md — subprocess auto-propagation, scope propagation, external_subprocess_policy
  • docs/ARCHITECTURE.md — how the audit hook works, scope mechanics, C guardian
  • docs/COOKBOOK.md — handling blocked connections in Django, Celery, retry decorators
  • SECURITY.md — threat model, what tethered does and does not protect against
  • examples/ — runnable scripts for every feature

Badge

Using tethered in your project? Add the badge to your README:

[![egress: tethered](https://img.shields.io/badge/egress-tethered-orange?labelColor=4B8BBE)](https://github.com/shcherbak-ai/tethered)

egress: tethered

Contributing

See CONTRIBUTING.md for development setup and guidelines.

Security

See SECURITY.md for reporting vulnerabilities and the full threat model.

License

MIT