quickstarted 0.2.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.
- quickstarted/__init__.py +24 -0
- quickstarted/_version.py +7 -0
- quickstarted/agents/__init__.py +4 -0
- quickstarted/agents/base.py +145 -0
- quickstarted/agents/claude.py +242 -0
- quickstarted/agents/gemini_agent.py +138 -0
- quickstarted/agents/openai_agent.py +167 -0
- quickstarted/agents/prompt.py +61 -0
- quickstarted/agents/registry.py +31 -0
- quickstarted/agents/replay.py +39 -0
- quickstarted/cli.py +250 -0
- quickstarted/docs.py +263 -0
- quickstarted/exec/__init__.py +89 -0
- quickstarted/exec/base.py +131 -0
- quickstarted/exec/docker.py +216 -0
- quickstarted/exec/local.py +16 -0
- quickstarted/exec/seatbelt.py +83 -0
- quickstarted/journey.py +182 -0
- quickstarted/net/__init__.py +5 -0
- quickstarted/net/proxy.py +274 -0
- quickstarted/net/proxy_main.py +53 -0
- quickstarted/pricing.py +93 -0
- quickstarted/report.py +206 -0
- quickstarted/results.py +166 -0
- quickstarted/run.py +240 -0
- quickstarted/sandbox.py +15 -0
- quickstarted/suite.py +203 -0
- quickstarted/trace.py +53 -0
- quickstarted/transport.py +88 -0
- quickstarted-0.2.0.dist-info/METADATA +256 -0
- quickstarted-0.2.0.dist-info/RECORD +34 -0
- quickstarted-0.2.0.dist-info/WHEEL +4 -0
- quickstarted-0.2.0.dist-info/entry_points.txt +3 -0
- quickstarted-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Unenforced local execution: a throwaway directory and a scrubbed environment.
|
|
2
|
+
|
|
3
|
+
Commands run as your user, on your filesystem, with your network. Absolute
|
|
4
|
+
paths reach your home directory and a command that ignores the proxy variables
|
|
5
|
+
reaches any host. Useful for developing journeys against your own code; wrong
|
|
6
|
+
for anything you did not write.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .base import ProcessExecutor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalExecutor(ProcessExecutor):
|
|
15
|
+
name = "local"
|
|
16
|
+
enforced = False
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Enforced local execution on macOS via sandbox-exec (Seatbelt).
|
|
2
|
+
|
|
3
|
+
This is the backend that makes the harness's central claim true rather than
|
|
4
|
+
merely intended. The kernel, not the agent's good manners, is what stops a
|
|
5
|
+
command from reaching a documentation host directly: all outbound network is
|
|
6
|
+
denied except the loopback port the harness proxy listens on, so every page
|
|
7
|
+
the agent reads is either a `read_docs` call or a recorded proxy request.
|
|
8
|
+
|
|
9
|
+
It also confines the blast radius of running commands that came out of a
|
|
10
|
+
stranger's quickstart: reads of the real home directory are denied and writes
|
|
11
|
+
are confined to the workspace.
|
|
12
|
+
|
|
13
|
+
Seatbelt is deprecated by Apple but present and functional; Docker is the
|
|
14
|
+
portable path, and CI should use it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .base import ExecutorError, ProcessExecutor
|
|
24
|
+
|
|
25
|
+
SANDBOX_EXEC = "/usr/bin/sandbox-exec"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def available() -> bool:
|
|
29
|
+
return platform.system() == "Darwin" and Path(SANDBOX_EXEC).is_file()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_profile(workspace: Path, proxy_port: int | None, real_home: Path) -> str:
|
|
33
|
+
"""Seatbelt profile source. Later rules win, so order is load-bearing."""
|
|
34
|
+
lines = [
|
|
35
|
+
"(version 1)",
|
|
36
|
+
"(deny default)",
|
|
37
|
+
# Running programs at all.
|
|
38
|
+
"(allow process-exec)",
|
|
39
|
+
"(allow process-fork)",
|
|
40
|
+
"(allow sysctl-read)",
|
|
41
|
+
"(allow mach-lookup)",
|
|
42
|
+
"(allow ipc-posix-shm)",
|
|
43
|
+
"(allow signal (target same-sandbox))",
|
|
44
|
+
# Interpreters and compilers read all over the system prefix.
|
|
45
|
+
"(allow file-read*)",
|
|
46
|
+
# ... but not the user's own files. This is the point.
|
|
47
|
+
f'(deny file-read* (subpath "{real_home}"))',
|
|
48
|
+
f'(allow file-read* (subpath "{workspace}"))',
|
|
49
|
+
# Writes stay in the workspace, plus the device files tools expect.
|
|
50
|
+
f'(allow file-write* (subpath "{workspace}"))',
|
|
51
|
+
'(allow file-write* (regex #"^/dev/"))',
|
|
52
|
+
"(allow file-ioctl)",
|
|
53
|
+
]
|
|
54
|
+
if proxy_port:
|
|
55
|
+
# The only route off the machine is the harness proxy.
|
|
56
|
+
lines.append(f'(allow network-outbound (remote ip "localhost:{proxy_port}"))')
|
|
57
|
+
# Loopback DNS and similar helpers travel over unix sockets; the proxy
|
|
58
|
+
# resolves real hostnames on the agent's behalf.
|
|
59
|
+
lines.append("(allow network-outbound (remote unix-socket))")
|
|
60
|
+
lines.append("(allow network-bind (local ip \"localhost:*\"))")
|
|
61
|
+
return "\n".join(lines) + "\n"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SeatbeltExecutor(ProcessExecutor):
|
|
65
|
+
name = "seatbelt"
|
|
66
|
+
enforced = True
|
|
67
|
+
|
|
68
|
+
def __init__(self, keep: bool = False, proxy_url: str | None = None):
|
|
69
|
+
if not available():
|
|
70
|
+
raise ExecutorError(
|
|
71
|
+
"seatbelt backend requires macOS with /usr/bin/sandbox-exec"
|
|
72
|
+
)
|
|
73
|
+
super().__init__(keep=keep, proxy_url=proxy_url)
|
|
74
|
+
port = None
|
|
75
|
+
if proxy_url:
|
|
76
|
+
port = int(proxy_url.rsplit(":", 1)[-1].strip("/"))
|
|
77
|
+
real_home = Path(os.path.expanduser("~")).resolve()
|
|
78
|
+
self.profile = build_profile(self.root.resolve(), port, real_home)
|
|
79
|
+
self._profile_path = self.root / "tmp" / ".quickstarted-sandbox.sb"
|
|
80
|
+
self._profile_path.write_text(self.profile, encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
def argv(self, command: str) -> list[str]:
|
|
83
|
+
return [SANDBOX_EXEC, "-f", str(self._profile_path), "bash", "-c", command]
|
quickstarted/journey.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Journey definitions: the YAML unit of testing.
|
|
2
|
+
|
|
3
|
+
A journey states a goal an agent should reach using only the target project's
|
|
4
|
+
documentation, plus a machine-checkable success assertion. Pass/fail is always
|
|
5
|
+
decided by the assertion script's exit code, never by a model's opinion.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .net.proxy import DEFAULT_NETWORK_ALLOW, host_matches
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JourneyError(ValueError):
|
|
20
|
+
"""Raised when a journey file is missing or malformed."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Budgets:
|
|
25
|
+
max_turns: int = 20
|
|
26
|
+
max_seconds: int = 900
|
|
27
|
+
max_command_seconds: int = 300
|
|
28
|
+
max_output_chars: int = 20_000
|
|
29
|
+
#: Hard ceiling on billable tokens for one run, cache traffic included.
|
|
30
|
+
#: 0 means unlimited. Prefer this to a dollar cap: it needs no price list
|
|
31
|
+
#: and cannot drift when vendors change their rates.
|
|
32
|
+
max_tokens: int = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Journey:
|
|
37
|
+
name: str
|
|
38
|
+
goal: str
|
|
39
|
+
docs_entrypoint: str
|
|
40
|
+
docs_allow: tuple[str, ...]
|
|
41
|
+
success_script: str
|
|
42
|
+
setup: tuple[str, ...] = ()
|
|
43
|
+
replay: tuple[str, ...] = ()
|
|
44
|
+
budgets: Budgets = field(default_factory=Budgets)
|
|
45
|
+
#: Hosts a shell may reach (package registries and the like). Documentation
|
|
46
|
+
#: hosts are deliberately excluded: they are readable only through the
|
|
47
|
+
#: recorded `read_docs` tool, which is what keeps attribution complete.
|
|
48
|
+
network_allow: tuple[str, ...] = DEFAULT_NETWORK_ALLOW
|
|
49
|
+
#: Hosts named by hand under `network.allow`/`network.only`. These beat the
|
|
50
|
+
#: docs-host rule, so a registry that also serves documentation stays
|
|
51
|
+
#: installable when the author says so.
|
|
52
|
+
network_explicit: tuple[str, ...] = ()
|
|
53
|
+
source: str = ""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def network_conflicts(self) -> tuple[str, ...]:
|
|
57
|
+
"""Docs hosts that installs usually need, which will now be refused.
|
|
58
|
+
|
|
59
|
+
Declaring a package registry as a documentation host is the easy
|
|
60
|
+
mistake: the proxy then refuses `pip install`, and the run fails for a
|
|
61
|
+
reason that has nothing to do with the documentation.
|
|
62
|
+
"""
|
|
63
|
+
return tuple(
|
|
64
|
+
host
|
|
65
|
+
for host in self.docs_allow
|
|
66
|
+
if host_matches(host, DEFAULT_NETWORK_ALLOW)
|
|
67
|
+
and not host_matches(host, self.network_explicit)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def attribution_gaps(self) -> tuple[str, ...]:
|
|
72
|
+
"""Docs hosts a shell may also reach, so their reads are not all logged."""
|
|
73
|
+
return tuple(
|
|
74
|
+
host for host in self.docs_allow if host_matches(host, self.network_explicit)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def host_allowed(self, url: str) -> bool:
|
|
78
|
+
host = (urlparse(url).hostname or "").lower()
|
|
79
|
+
if not host:
|
|
80
|
+
return False
|
|
81
|
+
return host_matches(host, self.docs_allow)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _require(data: dict, key: str, source: str):
|
|
85
|
+
if key not in data or data[key] in (None, "", []):
|
|
86
|
+
raise JourneyError(f"{source}: missing required field '{key}'")
|
|
87
|
+
return data[key]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _str_list(value, key: str, source: str) -> tuple[str, ...]:
|
|
91
|
+
if value is None:
|
|
92
|
+
return ()
|
|
93
|
+
if not isinstance(value, list) or not all(isinstance(x, str) for x in value):
|
|
94
|
+
raise JourneyError(f"{source}: '{key}' must be a list of strings")
|
|
95
|
+
return tuple(x.strip() for x in value if x.strip())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _normalize_host(entry: str, source: str) -> str:
|
|
99
|
+
entry = entry.strip().lower()
|
|
100
|
+
if "://" in entry:
|
|
101
|
+
entry = urlparse(entry).hostname or ""
|
|
102
|
+
entry = entry.strip("/")
|
|
103
|
+
if not entry or "/" in entry:
|
|
104
|
+
raise JourneyError(
|
|
105
|
+
f"{source}: docs.allow entries must be bare hostnames, got {entry!r}"
|
|
106
|
+
)
|
|
107
|
+
return entry
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_journey(path: str | Path) -> Journey:
|
|
111
|
+
path = Path(path)
|
|
112
|
+
source = str(path)
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
raise JourneyError(f"{source}: no such file")
|
|
115
|
+
try:
|
|
116
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
117
|
+
except yaml.YAMLError as exc:
|
|
118
|
+
raise JourneyError(f"{source}: invalid YAML: {exc}") from exc
|
|
119
|
+
if not isinstance(data, dict):
|
|
120
|
+
raise JourneyError(f"{source}: top level must be a mapping")
|
|
121
|
+
|
|
122
|
+
name = str(_require(data, "name", source))
|
|
123
|
+
goal = str(_require(data, "goal", source)).strip()
|
|
124
|
+
|
|
125
|
+
docs = _require(data, "docs", source)
|
|
126
|
+
if not isinstance(docs, dict):
|
|
127
|
+
raise JourneyError(f"{source}: 'docs' must be a mapping")
|
|
128
|
+
entrypoint = str(_require(docs, "entrypoint", source)).strip()
|
|
129
|
+
parsed = urlparse(entrypoint)
|
|
130
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
131
|
+
raise JourneyError(f"{source}: docs.entrypoint must be an http(s) URL")
|
|
132
|
+
allow = [
|
|
133
|
+
_normalize_host(h, source)
|
|
134
|
+
for h in _str_list(docs.get("allow"), "docs.allow", source)
|
|
135
|
+
]
|
|
136
|
+
entry_host = parsed.hostname.lower()
|
|
137
|
+
if entry_host not in allow:
|
|
138
|
+
allow.insert(0, entry_host)
|
|
139
|
+
|
|
140
|
+
network = data.get("network") or {}
|
|
141
|
+
if not isinstance(network, dict):
|
|
142
|
+
raise JourneyError(f"{source}: 'network' must be a mapping")
|
|
143
|
+
unknown_net = set(network) - {"allow", "only"}
|
|
144
|
+
if unknown_net:
|
|
145
|
+
raise JourneyError(f"{source}: unknown network keys: {sorted(unknown_net)}")
|
|
146
|
+
extra_net = [
|
|
147
|
+
_normalize_host(h, source)
|
|
148
|
+
for h in _str_list(network.get("allow"), "network.allow", source)
|
|
149
|
+
]
|
|
150
|
+
only_net = [
|
|
151
|
+
_normalize_host(h, source)
|
|
152
|
+
for h in _str_list(network.get("only"), "network.only", source)
|
|
153
|
+
]
|
|
154
|
+
network_allow = tuple(only_net) if only_net else DEFAULT_NETWORK_ALLOW + tuple(extra_net)
|
|
155
|
+
|
|
156
|
+
success = _require(data, "success", source)
|
|
157
|
+
if not isinstance(success, dict):
|
|
158
|
+
raise JourneyError(f"{source}: 'success' must be a mapping")
|
|
159
|
+
success_script = str(_require(success, "script", source))
|
|
160
|
+
|
|
161
|
+
budgets_data = data.get("budgets") or {}
|
|
162
|
+
if not isinstance(budgets_data, dict):
|
|
163
|
+
raise JourneyError(f"{source}: 'budgets' must be a mapping")
|
|
164
|
+
known = set(Budgets.__dataclass_fields__)
|
|
165
|
+
unknown = set(budgets_data) - known
|
|
166
|
+
if unknown:
|
|
167
|
+
raise JourneyError(f"{source}: unknown budget keys: {sorted(unknown)}")
|
|
168
|
+
budgets = Budgets(**{k: int(v) for k, v in budgets_data.items()})
|
|
169
|
+
|
|
170
|
+
return Journey(
|
|
171
|
+
name=name,
|
|
172
|
+
goal=goal,
|
|
173
|
+
docs_entrypoint=entrypoint,
|
|
174
|
+
docs_allow=tuple(allow),
|
|
175
|
+
success_script=success_script,
|
|
176
|
+
setup=_str_list(data.get("setup"), "setup", source),
|
|
177
|
+
replay=_str_list(data.get("replay"), "replay", source),
|
|
178
|
+
budgets=budgets,
|
|
179
|
+
network_allow=network_allow,
|
|
180
|
+
network_explicit=tuple(only_net or extra_net),
|
|
181
|
+
source=source,
|
|
182
|
+
)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""The harness-owned egress proxy.
|
|
2
|
+
|
|
3
|
+
Every packet an agent's shell sends leaves through here, which buys three
|
|
4
|
+
things the v0 design only hoped for:
|
|
5
|
+
|
|
6
|
+
1. **Enforcement.** The journey's network allowlist is applied by the proxy,
|
|
7
|
+
not by asking the agent nicely to use the `read_docs` tool.
|
|
8
|
+
2. **Attribution.** Documentation hosts are deliberately *not* reachable from
|
|
9
|
+
the shell. An agent that tries `curl https://docs.example.com/...` is
|
|
10
|
+
refused and the attempt is recorded, so the set of pages the agent read is
|
|
11
|
+
the set the trace knows about. Failure attribution stops being a guess.
|
|
12
|
+
3. **Honest infrastructure signals.** A connection refused by us and a
|
|
13
|
+
connection that failed upstream are different events, which is what lets a
|
|
14
|
+
run be classified as a docs failure rather than a flaky network.
|
|
15
|
+
|
|
16
|
+
The split matters: `docs.allow` hosts are readable only through `read_docs`,
|
|
17
|
+
while `network.allow` hosts (package registries and the like) are what a
|
|
18
|
+
shell may talk to in order to install things.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import select
|
|
24
|
+
import socket
|
|
25
|
+
import threading
|
|
26
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
# Package registries and source hosts a quickstart legitimately needs.
|
|
30
|
+
DEFAULT_NETWORK_ALLOW = (
|
|
31
|
+
"pypi.org",
|
|
32
|
+
"files.pythonhosted.org",
|
|
33
|
+
"registry.npmjs.org",
|
|
34
|
+
"github.com",
|
|
35
|
+
"codeload.github.com",
|
|
36
|
+
"objects.githubusercontent.com",
|
|
37
|
+
"crates.io",
|
|
38
|
+
"static.crates.io",
|
|
39
|
+
"proxy.golang.org",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_RELAY_CHUNK = 65536
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def host_matches(host: str, entries) -> bool:
|
|
46
|
+
host = (host or "").lower().strip(".")
|
|
47
|
+
for entry in entries:
|
|
48
|
+
entry = entry.lower().strip(".")
|
|
49
|
+
if host == entry or host.endswith("." + entry):
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EgressProxy:
|
|
55
|
+
"""A minimal forward proxy: CONNECT tunnels plus absolute-URI HTTP."""
|
|
56
|
+
|
|
57
|
+
#: Overridden to "0.0.0.0" when running as a sidecar for a container.
|
|
58
|
+
bind_host = "127.0.0.1"
|
|
59
|
+
|
|
60
|
+
def __init__(self, network_allow, docs_hosts=(), trace=None, explicit_allow=()):
|
|
61
|
+
self.network_allow = tuple(network_allow)
|
|
62
|
+
self.docs_hosts = tuple(docs_hosts)
|
|
63
|
+
#: Hosts the journey named under `network.allow` by hand. A host can be
|
|
64
|
+
#: both documentation and a package registry (PyPI is the obvious
|
|
65
|
+
#: case), and when an author says so explicitly, installs win over
|
|
66
|
+
#: attribution for that host. The override is recorded, not silent.
|
|
67
|
+
self.explicit_allow = tuple(explicit_allow)
|
|
68
|
+
self.trace = trace
|
|
69
|
+
self._server: ThreadingHTTPServer | None = None
|
|
70
|
+
self._thread: threading.Thread | None = None
|
|
71
|
+
self.blocked_docs_attempts = 0
|
|
72
|
+
|
|
73
|
+
# -- policy ---------------------------------------------------------
|
|
74
|
+
def decide(self, host: str) -> tuple[bool, str]:
|
|
75
|
+
"""(allowed, reason). Docs hosts lose to keep attribution complete."""
|
|
76
|
+
if host_matches(host, self.explicit_allow):
|
|
77
|
+
return True, "explicit_network_override"
|
|
78
|
+
if host_matches(host, self.docs_hosts):
|
|
79
|
+
return False, "docs_host_requires_read_docs"
|
|
80
|
+
if host_matches(host, self.network_allow):
|
|
81
|
+
return True, "allowlisted"
|
|
82
|
+
return False, "not_allowlisted"
|
|
83
|
+
|
|
84
|
+
def record(self, event: str, **data) -> None:
|
|
85
|
+
if self.trace is not None:
|
|
86
|
+
self.trace.add(event, **data)
|
|
87
|
+
|
|
88
|
+
# -- lifecycle ------------------------------------------------------
|
|
89
|
+
@property
|
|
90
|
+
def url(self) -> str:
|
|
91
|
+
if not self._server:
|
|
92
|
+
raise RuntimeError("proxy not started")
|
|
93
|
+
_host, port = self._server.server_address[:2]
|
|
94
|
+
return f"http://127.0.0.1:{port}"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def port(self) -> int:
|
|
98
|
+
if not self._server:
|
|
99
|
+
raise RuntimeError("proxy not started")
|
|
100
|
+
return int(self._server.server_address[1])
|
|
101
|
+
|
|
102
|
+
def start(self, port: int = 0) -> str:
|
|
103
|
+
proxy = self
|
|
104
|
+
|
|
105
|
+
class Handler(_ProxyHandler):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
Handler.proxy = proxy
|
|
109
|
+
self._server = ThreadingHTTPServer((self.bind_host, port), Handler)
|
|
110
|
+
self._server.daemon_threads = True
|
|
111
|
+
self._thread = threading.Thread(
|
|
112
|
+
target=self._server.serve_forever, kwargs={"poll_interval": 0.2}, daemon=True
|
|
113
|
+
)
|
|
114
|
+
self._thread.start()
|
|
115
|
+
return self.url
|
|
116
|
+
|
|
117
|
+
def stop(self) -> None:
|
|
118
|
+
if self._server:
|
|
119
|
+
self._server.shutdown()
|
|
120
|
+
self._server.server_close()
|
|
121
|
+
self._server = None
|
|
122
|
+
if self._thread:
|
|
123
|
+
self._thread.join(timeout=5)
|
|
124
|
+
self._thread = None
|
|
125
|
+
|
|
126
|
+
def __enter__(self) -> EgressProxy:
|
|
127
|
+
self.start()
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def __exit__(self, *exc) -> None:
|
|
131
|
+
self.stop()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _ProxyHandler(BaseHTTPRequestHandler):
|
|
135
|
+
proxy: EgressProxy
|
|
136
|
+
protocol_version = "HTTP/1.1"
|
|
137
|
+
|
|
138
|
+
def log_message(self, fmt, *args):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# -- helpers --------------------------------------------------------
|
|
142
|
+
def _refuse(self, host: str, reason: str, method: str) -> None:
|
|
143
|
+
self.proxy.record(
|
|
144
|
+
"egress_blocked", host=host, reason=reason, method=method
|
|
145
|
+
)
|
|
146
|
+
if reason == "docs_host_requires_read_docs":
|
|
147
|
+
self.proxy.blocked_docs_attempts += 1
|
|
148
|
+
body = (
|
|
149
|
+
f"BLOCKED by quickstarted: {host} is a documentation host. "
|
|
150
|
+
"Read documentation with the read_docs tool, not the shell, so "
|
|
151
|
+
"that every page you read is recorded.\n"
|
|
152
|
+
).encode()
|
|
153
|
+
else:
|
|
154
|
+
allowed = ", ".join(self.proxy.network_allow) or "(none)"
|
|
155
|
+
body = (
|
|
156
|
+
f"BLOCKED by quickstarted: {host} is not on this journey's network "
|
|
157
|
+
f"allowlist ({allowed}).\n"
|
|
158
|
+
).encode()
|
|
159
|
+
self.send_response(403)
|
|
160
|
+
self.send_header("Content-Type", "text/plain")
|
|
161
|
+
self.send_header("Content-Length", str(len(body)))
|
|
162
|
+
self.send_header("Connection", "close")
|
|
163
|
+
self.end_headers()
|
|
164
|
+
self.wfile.write(body)
|
|
165
|
+
|
|
166
|
+
def _upstream_failed(self, host: str, exc: Exception, method: str) -> None:
|
|
167
|
+
self.proxy.record(
|
|
168
|
+
"egress_error", host=host, error=str(exc), method=method
|
|
169
|
+
)
|
|
170
|
+
body = f"quickstarted proxy: upstream connection to {host} failed: {exc}\n".encode()
|
|
171
|
+
self.send_response(502)
|
|
172
|
+
self.send_header("Content-Type", "text/plain")
|
|
173
|
+
self.send_header("Content-Length", str(len(body)))
|
|
174
|
+
self.send_header("Connection", "close")
|
|
175
|
+
self.end_headers()
|
|
176
|
+
self.wfile.write(body)
|
|
177
|
+
|
|
178
|
+
# -- CONNECT (https) -------------------------------------------------
|
|
179
|
+
def do_CONNECT(self) -> None:
|
|
180
|
+
target = self.path
|
|
181
|
+
host, _, port_s = target.partition(":")
|
|
182
|
+
port = int(port_s or 443)
|
|
183
|
+
allowed, reason = self.proxy.decide(host)
|
|
184
|
+
if not allowed:
|
|
185
|
+
self._refuse(host, reason, "CONNECT")
|
|
186
|
+
return
|
|
187
|
+
try:
|
|
188
|
+
upstream = socket.create_connection((host, port), timeout=30)
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
self._upstream_failed(host, exc, "CONNECT")
|
|
191
|
+
return
|
|
192
|
+
self.proxy.record("egress_allowed", host=host, port=port, method="CONNECT")
|
|
193
|
+
self.send_response(200, "Connection established")
|
|
194
|
+
self.end_headers()
|
|
195
|
+
try:
|
|
196
|
+
self.wfile.flush()
|
|
197
|
+
except OSError:
|
|
198
|
+
upstream.close()
|
|
199
|
+
return
|
|
200
|
+
self._relay(self.connection, upstream)
|
|
201
|
+
|
|
202
|
+
def _relay(self, client: socket.socket, upstream: socket.socket) -> None:
|
|
203
|
+
sockets = [client, upstream]
|
|
204
|
+
try:
|
|
205
|
+
while True:
|
|
206
|
+
readable, _, errored = select.select(sockets, [], sockets, 60)
|
|
207
|
+
if errored or not readable:
|
|
208
|
+
break
|
|
209
|
+
for sock in readable:
|
|
210
|
+
other = upstream if sock is client else client
|
|
211
|
+
try:
|
|
212
|
+
data = sock.recv(_RELAY_CHUNK)
|
|
213
|
+
except OSError:
|
|
214
|
+
return
|
|
215
|
+
if not data:
|
|
216
|
+
return
|
|
217
|
+
try:
|
|
218
|
+
other.sendall(data)
|
|
219
|
+
except OSError:
|
|
220
|
+
return
|
|
221
|
+
finally:
|
|
222
|
+
upstream.close()
|
|
223
|
+
|
|
224
|
+
# -- plain HTTP ------------------------------------------------------
|
|
225
|
+
def _forward(self) -> None:
|
|
226
|
+
parsed = urlparse(self.path)
|
|
227
|
+
host = parsed.hostname or ""
|
|
228
|
+
if not host:
|
|
229
|
+
self._refuse(host or "(relative-url)", "not_allowlisted", self.command)
|
|
230
|
+
return
|
|
231
|
+
allowed, reason = self.proxy.decide(host)
|
|
232
|
+
if not allowed:
|
|
233
|
+
self._refuse(host, reason, self.command)
|
|
234
|
+
return
|
|
235
|
+
port = parsed.port or 80
|
|
236
|
+
path = parsed.path or "/"
|
|
237
|
+
if parsed.query:
|
|
238
|
+
path += "?" + parsed.query
|
|
239
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
240
|
+
body = self.rfile.read(length) if length else b""
|
|
241
|
+
try:
|
|
242
|
+
upstream = socket.create_connection((host, port), timeout=30)
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
self._upstream_failed(host, exc, self.command)
|
|
245
|
+
return
|
|
246
|
+
self.proxy.record(
|
|
247
|
+
"egress_allowed", host=host, port=port, method=self.command, path=path
|
|
248
|
+
)
|
|
249
|
+
try:
|
|
250
|
+
request = [f"{self.command} {path} HTTP/1.1", f"Host: {parsed.netloc}"]
|
|
251
|
+
for key, value in self.headers.items():
|
|
252
|
+
if key.lower() in ("proxy-connection", "connection", "host"):
|
|
253
|
+
continue
|
|
254
|
+
request.append(f"{key}: {value}")
|
|
255
|
+
request.append("Connection: close")
|
|
256
|
+
blob = ("\r\n".join(request) + "\r\n\r\n").encode() + body
|
|
257
|
+
upstream.sendall(blob)
|
|
258
|
+
while True:
|
|
259
|
+
chunk = upstream.recv(_RELAY_CHUNK)
|
|
260
|
+
if not chunk:
|
|
261
|
+
break
|
|
262
|
+
self.wfile.write(chunk)
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
finally:
|
|
266
|
+
upstream.close()
|
|
267
|
+
self.close_connection = True
|
|
268
|
+
|
|
269
|
+
do_GET = _forward
|
|
270
|
+
do_POST = _forward
|
|
271
|
+
do_HEAD = _forward
|
|
272
|
+
do_PUT = _forward
|
|
273
|
+
do_DELETE = _forward
|
|
274
|
+
do_PATCH = _forward
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Entrypoint for running the egress proxy as a sidecar container.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free and importable as a bare script so it can run in a stock
|
|
4
|
+
`python:*-alpine` image with only this directory mounted: the sidecar must not
|
|
5
|
+
need quickstarted's own dependencies installed.
|
|
6
|
+
|
|
7
|
+
Events are written to stdout as JSONL; the harness collects them with
|
|
8
|
+
`docker logs` and merges them into the run trace.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
from proxy import EgressProxy
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StdoutTrace:
|
|
25
|
+
"""Duck-types Trace.add, emitting one JSON object per event."""
|
|
26
|
+
|
|
27
|
+
def add(self, type: str, **data) -> None:
|
|
28
|
+
sys.stdout.write(json.dumps({"ts": time.time(), "type": type, **data}) + "\n")
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hosts(name: str) -> tuple[str, ...]:
|
|
33
|
+
raw = os.environ.get(name, "")
|
|
34
|
+
return tuple(h for h in (x.strip() for x in raw.split(",")) if h)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> int:
|
|
38
|
+
port = int(os.environ.get("QUICKSTARTED_PROXY_PORT", "8080"))
|
|
39
|
+
proxy = EgressProxy(
|
|
40
|
+
network_allow=_hosts("QUICKSTARTED_NETWORK_ALLOW"),
|
|
41
|
+
docs_hosts=_hosts("QUICKSTARTED_DOCS_HOSTS"),
|
|
42
|
+
trace=StdoutTrace(),
|
|
43
|
+
)
|
|
44
|
+
# Bind on all interfaces inside the sidecar so the sandbox container can
|
|
45
|
+
# reach it; the container is on an internal network with no route out.
|
|
46
|
+
proxy.bind_host = "0.0.0.0"
|
|
47
|
+
proxy.start(port=port)
|
|
48
|
+
threading.Event().wait()
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
raise SystemExit(main())
|