steerable-egress-proxy 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ """steerable-egress-proxy — local allow-listing CONNECT egress proxy.
2
+
3
+ Optional component. See docs/spec/safety.md "Egress allow-list": OS
4
+ sandboxes degrade to port-level (Seatbelt) or namespace-level (bwrap)
5
+ egress; per-host enforcement needs a local proxy that owns the host list.
6
+ """
7
+
8
+ from .proxy import (
9
+ AllowList,
10
+ EgressProxyServer,
11
+ ProxyConfig,
12
+ parse_allow_entry,
13
+ )
14
+
15
+ __all__ = [
16
+ "AllowList",
17
+ "EgressProxyServer",
18
+ "ProxyConfig",
19
+ "parse_allow_entry",
20
+ ]
@@ -0,0 +1,75 @@
1
+ """CLI: `steerable-egress-proxy --bind 127.0.0.1:8899 --allow api.deepseek.com ...`
2
+
3
+ Misconfiguration fails loud at startup: no `--allow` entries (or a
4
+ malformed one) exits non-zero before the socket opens — a proxy with an
5
+ unintended list is worse than no proxy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ import sys
13
+
14
+ from .proxy import AllowList, EgressProxyServer, ProxyConfig
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ parser = argparse.ArgumentParser(
19
+ prog="steerable-egress-proxy",
20
+ description="Local allow-listing CONNECT egress proxy (v1: no TLS interception).",
21
+ )
22
+ parser.add_argument(
23
+ "--bind",
24
+ default="127.0.0.1:8899",
25
+ help="listen address (default 127.0.0.1:8899)",
26
+ )
27
+ parser.add_argument(
28
+ "--allow",
29
+ action="append",
30
+ default=[],
31
+ metavar="HOST[:PORT]",
32
+ help="allowed CONNECT target; repeatable. Bare host allows 443 and 80.",
33
+ )
34
+ parser.add_argument(
35
+ "--connect-timeout",
36
+ type=float,
37
+ default=10.0,
38
+ help="upstream dial timeout in seconds (default 10)",
39
+ )
40
+ args = parser.parse_args(argv)
41
+
42
+ bind_host, sep, bind_port_s = args.bind.rpartition(":")
43
+ if not sep or not bind_host:
44
+ print(f"error: --bind must be host:port, got {args.bind!r}", file=sys.stderr)
45
+ return 2
46
+ try:
47
+ bind_port = int(bind_port_s)
48
+ allow = AllowList(args.allow)
49
+ config = ProxyConfig(
50
+ allow=allow,
51
+ bind_host=bind_host,
52
+ bind_port=bind_port,
53
+ connect_timeout_s=args.connect_timeout,
54
+ )
55
+ except ValueError as exc:
56
+ print(f"error: {exc}", file=sys.stderr)
57
+ return 2
58
+
59
+ server = EgressProxyServer(config)
60
+
61
+ async def run() -> None:
62
+ try:
63
+ await server.serve()
64
+ except asyncio.CancelledError:
65
+ await server.close()
66
+
67
+ try:
68
+ asyncio.run(run())
69
+ except KeyboardInterrupt:
70
+ pass
71
+ return 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ sys.exit(main())
@@ -0,0 +1,256 @@
1
+ """Allow-listing CONNECT forward proxy (v1: HTTPS tunneling only).
2
+
3
+ The proxy accepts only ``CONNECT host:port`` requests, checks the target
4
+ against an explicit allow-list, dials, and pipes bytes bidirectionally.
5
+ Everything else fails closed:
6
+
7
+ - target not on the list → 403
8
+ - non-CONNECT method → 405 (no plain-HTTP forwarding in v1)
9
+ - malformed request line / headers → 400
10
+ - unreachable target → 502
11
+
12
+ TLS is NOT intercepted: the proxy sees the CONNECT target only, which is
13
+ exactly the metadata the host allow-list needs. v1 scope per PARITY_TODO
14
+ 0.4.2 — no TLS interception, no plain-HTTP forwarding.
15
+
16
+ Bounds that keep the security boundary tight:
17
+
18
+ - request head capped at ``MAX_HEAD_BYTES`` (16 KiB) → 431 over cap
19
+ - CONNECT dial bounded by ``connect_timeout_s``
20
+ - an empty allow-list is a configuration error, not "open" — constructing
21
+ ``AllowList([])`` raises ``ValueError`` (fail loud, never silently open)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ import re
29
+ from dataclasses import dataclass
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ MAX_HEAD_BYTES = 16 * 1024
34
+ _DEFAULT_CONNECT_TIMEOUT_S = 10.0
35
+ #: Ports implied by a bare `host` allow entry — mirrors the Seatbelt
36
+ #: profile semantics in docs/spec/safety.md so the two layers agree.
37
+ _BARE_HOST_PORTS = frozenset({443, 80})
38
+
39
+ _ENTRY_RE = re.compile(
40
+ r"^(?P<host>[A-Za-z0-9._-]+|\[[0-9a-fA-F:]+\])(?::(?P<port>\d{1,5}))?$"
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class AllowEntry:
46
+ host: str
47
+ ports: frozenset[int] # empty frozenset is never stored; see parse
48
+
49
+ def allows(self, host: str, port: int) -> bool:
50
+ return self.host == host.lower() and port in self.ports
51
+
52
+
53
+ def parse_allow_entry(raw: str) -> AllowEntry:
54
+ """Parse one `host` or `host:port` entry. Bare hosts allow 443 and 80.
55
+
56
+ Raises ValueError on anything malformed — a bad entry must never
57
+ silently widen or narrow the list.
58
+ """
59
+ text = raw.strip()
60
+ m = _ENTRY_RE.match(text)
61
+ if not m:
62
+ raise ValueError(f"invalid allow entry {raw!r}: expected host or host:port")
63
+ host = m.group("host").lower()
64
+ if host.startswith("[") and host.endswith("]"):
65
+ host = host[1:-1]
66
+ port_s = m.group("port")
67
+ if port_s is None:
68
+ return AllowEntry(host, frozenset(_BARE_HOST_PORTS))
69
+ port = int(port_s)
70
+ if not 1 <= port <= 65535:
71
+ raise ValueError(f"invalid allow entry {raw!r}: port out of range")
72
+ return AllowEntry(host, frozenset({port}))
73
+
74
+
75
+ class AllowList:
76
+ """Closed set of allowed CONNECT targets. Empty input is an error."""
77
+
78
+ def __init__(self, entries: list[str]):
79
+ if not entries:
80
+ raise ValueError(
81
+ "allow-list is empty: an egress proxy with no entries is "
82
+ "either a mistake or should not be run at all"
83
+ )
84
+ self._entries = tuple(parse_allow_entry(e) for e in entries)
85
+
86
+ @property
87
+ def entries(self) -> tuple[AllowEntry, ...]:
88
+ return self._entries
89
+
90
+ def allows(self, host: str, port: int) -> bool:
91
+ host = host.lower()
92
+ return any(e.allows(host, port) for e in self._entries)
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class ProxyConfig:
97
+ allow: AllowList # required — fail-closed by construction
98
+ bind_host: str = "127.0.0.1"
99
+ bind_port: int = 8899
100
+ connect_timeout_s: float = _DEFAULT_CONNECT_TIMEOUT_S
101
+
102
+ def __post_init__(self) -> None:
103
+ if self.allow is None:
104
+ raise ValueError("ProxyConfig.allow is required (fail-closed)")
105
+ # 0 is valid: ephemeral bind (tests, supervised spawns that read the
106
+ # port back from `bound_port`).
107
+ if not 0 <= self.bind_port <= 65535:
108
+ raise ValueError(f"bind_port out of range: {self.bind_port}")
109
+
110
+
111
+ class EgressProxyServer:
112
+ """Asyncio CONNECT proxy. `await serve()` runs until cancelled."""
113
+
114
+ def __init__(self, config: ProxyConfig):
115
+ self.config = config
116
+ self._server: asyncio.AbstractServer | None = None
117
+
118
+ @property
119
+ def bound_port(self) -> int:
120
+ if self._server and self._server.sockets:
121
+ return int(self._server.sockets[0].getsockname()[1])
122
+ return self.config.bind_port
123
+
124
+ async def serve(self) -> None:
125
+ self._server = await asyncio.start_server(
126
+ self._handle,
127
+ self.config.bind_host,
128
+ self.config.bind_port,
129
+ )
130
+ logger.info(
131
+ "egress proxy on %s:%s (%d allow entries)",
132
+ self.config.bind_host,
133
+ self.bound_port,
134
+ len(self.config.allow.entries),
135
+ )
136
+ async with self._server:
137
+ await self._server.serve_forever()
138
+
139
+ async def close(self) -> None:
140
+ if self._server:
141
+ self._server.close()
142
+ await self._server.wait_closed()
143
+ self._server = None
144
+
145
+ # ---- connection handling -------------------------------------------
146
+
147
+ async def _handle(
148
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
149
+ ) -> None:
150
+ try:
151
+ head = await self._read_head(reader)
152
+ if head is None:
153
+ await self._reply(writer, 431, "Request Header Fields Too Large")
154
+ return
155
+ parsed = self._parse_request_line(head)
156
+ if parsed is None:
157
+ await self._reply(writer, 400, "Bad Request")
158
+ return
159
+ method, host, port = parsed
160
+ if method != "CONNECT":
161
+ # Checked before authority validity: a GET with a weird
162
+ # target is still a 405, not a 400.
163
+ await self._reply(writer, 405, "Method Not Allowed")
164
+ return
165
+ if host is None or port is None:
166
+ await self._reply(writer, 400, "Bad Request")
167
+ return
168
+ if not self.config.allow.allows(host, port):
169
+ logger.info("deny %s:%d (not on allow-list)", host, port)
170
+ await self._reply(writer, 403, "Forbidden")
171
+ return
172
+ try:
173
+ upstream_r, upstream_w = await asyncio.wait_for(
174
+ asyncio.open_connection(host, port),
175
+ timeout=self.config.connect_timeout_s,
176
+ )
177
+ except (OSError, asyncio.TimeoutError):
178
+ await self._reply(writer, 502, "Bad Gateway")
179
+ return
180
+ await self._reply(writer, 200, "Connection Established")
181
+ await self._tunnel(reader, writer, upstream_r, upstream_w)
182
+ except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
183
+ pass # client or upstream hung up mid-stream — normal teardown
184
+ finally:
185
+ try:
186
+ writer.close()
187
+ await writer.wait_closed()
188
+ except OSError:
189
+ pass # peer already gone
190
+
191
+ async def _read_head(self, reader: asyncio.StreamReader) -> bytes | None:
192
+ """Read up to the blank line ending the request head; None = over cap."""
193
+ data = b""
194
+ while b"\r\n\r\n" not in data:
195
+ chunk = await reader.read(min(4096, MAX_HEAD_BYTES - len(data) + 4))
196
+ if not chunk:
197
+ return data if data else b""
198
+ data += chunk
199
+ if len(data) > MAX_HEAD_BYTES:
200
+ return None
201
+ return data
202
+
203
+ @staticmethod
204
+ def _parse_request_line(head: bytes) -> tuple[str, str | None, int | None] | None:
205
+ """Split the request line. `(method, None, None)` means the method is
206
+ fine but the authority is not a CONNECT target — caller maps that to
207
+ 405-before-400 ordering."""
208
+ try:
209
+ first = head.split(b"\r\n", 1)[0].decode("ascii")
210
+ except UnicodeDecodeError:
211
+ return None
212
+ parts = first.split(" ")
213
+ if len(parts) != 3 or not parts[2].startswith("HTTP/"):
214
+ return None
215
+ method, authority = parts[0].upper(), parts[1]
216
+ if ":" not in authority:
217
+ return (method, None, None)
218
+ host, _, port_s = authority.rpartition(":")
219
+ try:
220
+ port = int(port_s)
221
+ except ValueError:
222
+ return (method, None, None)
223
+ if not host or not 1 <= port <= 65535:
224
+ return (method, None, None)
225
+ return method, host.lower(), port
226
+
227
+ @staticmethod
228
+ async def _reply(writer: asyncio.StreamWriter, code: int, reason: str) -> None:
229
+ writer.write(f"HTTP/1.1 {code} {reason}\r\ncontent-length: 0\r\n\r\n".encode())
230
+ await writer.drain()
231
+
232
+ async def _tunnel(
233
+ self,
234
+ client_r: asyncio.StreamReader,
235
+ client_w: asyncio.StreamWriter,
236
+ upstream_r: asyncio.StreamReader,
237
+ upstream_w: asyncio.StreamWriter,
238
+ ) -> None:
239
+ async def pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
240
+ while True:
241
+ chunk = await src.read(64 * 1024)
242
+ if not chunk:
243
+ break
244
+ dst.write(chunk)
245
+ await dst.drain()
246
+ if dst.can_write_eof():
247
+ dst.write_eof()
248
+
249
+ up = asyncio.create_task(pipe(client_r, upstream_w))
250
+ down = asyncio.create_task(pipe(upstream_r, client_w))
251
+ try:
252
+ await asyncio.wait({up, down}, return_when=asyncio.FIRST_COMPLETED)
253
+ finally:
254
+ for t in (up, down):
255
+ t.cancel()
256
+ upstream_w.close()
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: steerable-egress-proxy
3
+ Version: 0.3.0
4
+ Summary: Steerable optional component — local allow-listing CONNECT egress proxy. Gives per-host egress control on platforms whose sandbox can only pin ports (Seatbelt) or only drop the network namespace (bwrap): confine the sidecar to localhost:<proxy> and let the proxy own the host list.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # steerable-egress-proxy
9
+
10
+ Optional Steerable component: a local, allow-listing `CONNECT` egress proxy.
11
+
12
+ ## Why it exists
13
+
14
+ The sidecar's OS sandboxes cannot do per-host egress on their own:
15
+ macOS Seatbelt degrades hostnames to ports (`*:443`), and Linux bwrap can
16
+ only drop the whole network namespace. The remedy on both is the same —
17
+ confine the sidecar to `localhost:<proxy port>` and let this proxy own the
18
+ host list. See `docs/spec/safety.md` ("Egress allow-list") for the full
19
+ threat model.
20
+
21
+ ## Usage
22
+
23
+ ```sh
24
+ steerable-egress-proxy --bind 127.0.0.1:8899 \
25
+ --allow api.deepseek.com \
26
+ --allow localhost:11434
27
+ ```
28
+
29
+ - Only `CONNECT host:port` is served (HTTPS tunneling). Plain-HTTP
30
+ forwarding and TLS interception are deliberately out of v1 scope.
31
+ - Bare `host` entries allow ports 443 and 80, mirroring the Seatbelt
32
+ profile semantics so the two layers agree.
33
+ - Fail-closed by construction: an empty allow-list is a startup error,
34
+ not "open"; targets off the list get `403`; non-CONNECT gets `405`.
35
+ - Request heads are capped at 16 KiB; upstream dials time out after 10s.
36
+
37
+ Wire-up with the sandbox: set the sidecar's egress allow-list to
38
+ `localhost:8899` only, and point the sidecar's HTTP stack at the proxy
39
+ (`HTTPS_PROXY=http://127.0.0.1:8899` — httpx honors it). The sandbox then
40
+ pins the process to the proxy and the proxy enforces the host list.
@@ -0,0 +1,8 @@
1
+ steerable_egress_proxy/__init__.py,sha256=cWEeWjO9VxS2ar8PsbiAUhs7E-fpeZ3Rz72A91U5M64,492
2
+ steerable_egress_proxy/__main__.py,sha256=gMwqOQ7XPLnnUMR9MOkg0qFUiEu3ztuE06QbA91oTCI,2070
3
+ steerable_egress_proxy/proxy.py,sha256=IW0WMbjOBztqQZui1MqJO1GxExkLvsNGgaE4sjAclKo,9469
4
+ steerable_egress_proxy-0.3.0.dist-info/METADATA,sha256=uE-hp5-PrD7ZSBPxc9h07mVoa5e2JohAr10R8iL2S0A,1794
5
+ steerable_egress_proxy-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ steerable_egress_proxy-0.3.0.dist-info/entry_points.txt,sha256=HVK6cSmCj9sZx8uhlO6GiMnq1VcQ4wwd2qZr4Eu6ceE,80
7
+ steerable_egress_proxy-0.3.0.dist-info/top_level.txt,sha256=_MpXoKraWMC_SI5PcvoptFodbc5gXXRYzhqcxqiF7c8,23
8
+ steerable_egress_proxy-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ steerable-egress-proxy = steerable_egress_proxy.__main__:main
@@ -0,0 +1 @@
1
+ steerable_egress_proxy