synapse-orch-ai 1.9.0 → 1.9.2
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.
- package/README.md +4 -0
- package/backend/core/config.py +10 -0
- package/backend/core/egress_guard.py +202 -0
- package/backend/core/expressions.py +522 -0
- package/backend/core/file_roots.py +80 -0
- package/backend/core/internal_auth.py +129 -20
- package/backend/core/mcp_client.py +62 -1
- package/backend/core/models.py +20 -2
- package/backend/core/orchestration/steps.py +38 -75
- package/backend/core/react_engine.py +8 -0
- package/backend/core/routes/settings.py +5 -1
- package/backend/core/routes/tools.py +28 -4
- package/backend/core/safe_fetch.py +106 -0
- package/backend/core/scale/worker_server_module.py +22 -1
- package/backend/core/server.py +42 -25
- package/backend/tools/code_search.py +23 -21
- package/backend/tools/file_reader.py +12 -2
- package/backend/tools/pdf_parser.py +5 -5
- package/backend/tools/sandbox.py +6 -2
- package/backend/tools/web_scraper.py +80 -0
- package/backend/tools/xlsx_parser.py +5 -5
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/server/app/_global-error.html +1 -1
- package/frontend-build/.next/server/app/_global-error.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/api/agent-types/route.js +3 -2
- package/frontend-build/.next/server/app/api/agent-types/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js +3 -2
- package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/login/route.js +3 -2
- package/frontend-build/.next/server/app/api/auth/login/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/logout/route.js +2 -1
- package/frontend-build/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/status/route.js +3 -2
- package/frontend-build/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/builder/chat/route.js +3 -2
- package/frontend-build/.next/server/app/api/builder/chat/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/builder/resume/route.js +3 -2
- package/frontend-build/.next/server/app/api/builder/resume/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/chat/route.js +3 -2
- package/frontend-build/.next/server/app/api/chat/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/chat/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/chat/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js +3 -2
- package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/logs/[type]/route.js +3 -2
- package/frontend-build/.next/server/app/api/logs/[type]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/models/route.js +3 -2
- package/frontend-build/.next/server/app/api/models/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/notifications/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/icon.png/route.js +2 -1
- package/frontend-build/.next/server/app/icon.png/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0.-gycy._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0~bybkp._.js → [root-of-the-server]__003vgom._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t4a96i._.js → [root-of-the-server]__03ek9xq._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__07vf6z1._.js → [root-of-the-server]__03x0ii~._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0wqyl-j._.js → [root-of-the-server]__03zuar1._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0zl78ks._.js → [root-of-the-server]__04ks5f_._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t.cymd._.js → [root-of-the-server]__0_fafcz._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0_~_1-l._.js +10 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0fa0k5_._.js → [root-of-the-server]__0dbuutb._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0hnd6qu._.js → [root-of-the-server]__0hlm8c7._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0is-hlj._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0n-ayat._.js → [root-of-the-server]__0i~uoki._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0js9sik._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0ncun1_._.js +1 -1
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0i0g0o9._.js → [root-of-the-server]__0oe4zjm._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0av_50r._.js → [root-of-the-server]__0v4clie._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0w02yhg._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0wbbjuq._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0-0m2_n._.js → [root-of-the-server]__0wgri4w._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0xtvg9h._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__01abhvg._.js → [root-of-the-server]__0yuz2ug._.js} +2 -2
- package/frontend-build/.next/server/chunks/node_modules_next_124cnn1._.js +3 -0
- package/frontend-build/.next/server/chunks/node_modules_next_dist_12gp6q5._.js +6 -0
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- package/frontend-build/.next/server/pages/404.html +1 -1
- package/frontend-build/.next/server/pages/500.html +1 -1
- package/frontend-build/.next/server/server-reference-manifest.js +1 -1
- package/frontend-build/.next/server/server-reference-manifest.json +1 -1
- package/frontend-build/.next/static/chunks/{0a-yeaiijslpf.css → 13upr_rmz7sy8.css} +5 -5
- package/package.json +1 -1
- package/frontend-build/.next/server/chunks/[root-of-the-server]__08ei_wz._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0_qd3xa._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0aw6-s5._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0j8-xkl._.js +0 -13
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0uulfit._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__10fof-m._.js +0 -3
- /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ docker run -d \
|
|
|
64
64
|
|
|
65
65
|
Then open `http://localhost:3000`. See the [Docker guide](https://docs.synapseorch.com/getting-started/installation#docker) in the docs for custom ports and environment variable configuration.
|
|
66
66
|
|
|
67
|
+
> **Security:** publish only the frontend port `3000` — the backend (`8765`) serves an internal API and should not be exposed on `0.0.0.0` (docker-compose binds it to `127.0.0.1`). The container auto-generates a shared internal token so that surface is authenticated by default. For any network-reachable deployment, enable **login** (Settings → Security) and/or set `allow_stdio_mcp=false`, since registering a stdio MCP server launches local commands.
|
|
68
|
+
|
|
67
69
|
### Upgrading
|
|
68
70
|
|
|
69
71
|
| Install method | Upgrade command |
|
|
@@ -73,6 +75,8 @@ Then open `http://localhost:3000`. See the [Docker guide](https://docs.synapseor
|
|
|
73
75
|
| npm | `npm update -g synapse-orch-ai` |
|
|
74
76
|
| Docker | `docker pull synapseorchai/synapse-ai:latest` |
|
|
75
77
|
|
|
78
|
+
> **Upgrade note (security hardening):** the backend now auto-generates a shared internal token and, under docker-compose, binds its port to `127.0.0.1` by default. The UI and its API are unaffected (the frontend proxies the backend internally). If you previously called the **backend directly** on `:8765` (e.g. external API clients), either point them at the frontend instead — `http://host:3000/api/v1|v2/...` — or set `SYNAPSE_BACKEND_BIND=0.0.0.0` in `.env` to keep publishing `:8765`.
|
|
79
|
+
|
|
76
80
|
---
|
|
77
81
|
|
|
78
82
|
## Scale Mode
|
package/backend/core/config.py
CHANGED
|
@@ -108,6 +108,16 @@ def load_settings():
|
|
|
108
108
|
"messaging_enabled": True,
|
|
109
109
|
"embed_code": False,
|
|
110
110
|
"bash_allowed_dirs": [],
|
|
111
|
+
# ── MCP stdio server registration (security) ──
|
|
112
|
+
# stdio MCP servers launch local OS processes (npx/uvx/python/…). This is
|
|
113
|
+
# a powerful, admin-only capability. Allowed by default for local
|
|
114
|
+
# single-user use, but force-disabled in scale mode (multi-tenant /
|
|
115
|
+
# network-exposed) — see stdio_mcp_allowed(). See GHSA-3j67-x3j8-r32x.
|
|
116
|
+
"allow_stdio_mcp": True,
|
|
117
|
+
# Optional hardening: when non-empty, only these command basenames may be
|
|
118
|
+
# registered as stdio servers. Empty = allow any command in PATH.
|
|
119
|
+
# Recommended for exposed UIs: ["npx","uvx","uv","node","python","python3","docker"]
|
|
120
|
+
"mcp_command_allowlist": [],
|
|
111
121
|
"login_enabled": False,
|
|
112
122
|
"login_username": "",
|
|
113
123
|
"login_password_hash": "",
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Egress policy for tools that fetch user- or model-supplied URLs.
|
|
2
|
+
|
|
3
|
+
A tool's URLs are data, not configuration: they arrive from a prompt, and a
|
|
4
|
+
prompt can be steered by whatever the last scraped page said. A fetcher that
|
|
5
|
+
goes wherever it is pointed will sooner or later be pointed at the network
|
|
6
|
+
the process itself runs on — the cloud metadata endpoints (169.254.169.254,
|
|
7
|
+
169.254.170.2), loopback services on its own host, or anything else the
|
|
8
|
+
subnet can reach. On a cloud runtime the metadata endpoint alone turns one
|
|
9
|
+
injected URL into live credentials.
|
|
10
|
+
|
|
11
|
+
The policy: http(s) only, and only to addresses that are actually on the
|
|
12
|
+
public internet. Every address a hostname resolves to is checked, not just
|
|
13
|
+
the first — a name with one public and one private record must not pass.
|
|
14
|
+
|
|
15
|
+
`EGRESS_ALLOW_HOSTS` (comma-separated hostnames) exempts specific hosts from
|
|
16
|
+
the address checks, for installs that genuinely scrape an intranet or a
|
|
17
|
+
localhost dev server, and for tests dialing loopback stand-ins. Empty —
|
|
18
|
+
deny — is the default.
|
|
19
|
+
|
|
20
|
+
The DNS-rebinding window (resolution here vs. resolution at connect time) is
|
|
21
|
+
deliberately not closed: closing it means owning socket creation and TLS SNI
|
|
22
|
+
handling in every fetch path. Network-level egress control is the layer for
|
|
23
|
+
that; this module is the in-process guard in front of it.
|
|
24
|
+
|
|
25
|
+
Deliberately stdlib-only, and deliberately not a Settings consumer: it is
|
|
26
|
+
imported by tool subprocesses that must construct in any environment.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import ipaddress
|
|
33
|
+
import os
|
|
34
|
+
import socket
|
|
35
|
+
import time
|
|
36
|
+
from urllib.parse import urlparse
|
|
37
|
+
|
|
38
|
+
#: Carrier-grade NAT. Not covered by `is_private`, and it is where several
|
|
39
|
+
#: managed-Kubernetes overlays put internal services.
|
|
40
|
+
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
|
41
|
+
|
|
42
|
+
#: One resolution per host per window is enough for a page's subresources;
|
|
43
|
+
#: short enough that the cache is not itself a rebinding aid.
|
|
44
|
+
_RESOLVE_TTL_SECONDS = 60.0
|
|
45
|
+
_RESOLVE_CACHE_MAX = 512
|
|
46
|
+
|
|
47
|
+
_resolve_cache: dict[str, tuple[float, list[str]]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BlockedEgress(ValueError):
|
|
51
|
+
"""A URL that must not be fetched. The message is safe to echo to the model."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MalformedUrl(BlockedEgress):
|
|
55
|
+
"""Not a URL with a scheme, so it names no destination to check.
|
|
56
|
+
|
|
57
|
+
A subclass for the same reason `UnresolvedHost` is: the scraper should
|
|
58
|
+
refuse what it cannot evaluate. A custom HTTP tool should not, because the
|
|
59
|
+
commonest way to get here is an unrendered `{{global.api_base}}` — a missing
|
|
60
|
+
variable, whose own error message names the variable and is worth more than
|
|
61
|
+
this one. Nothing is conceded: a string with no scheme is one httpx refuses
|
|
62
|
+
to send anywhere. A URL with a scheme that is simply not http stays a plain
|
|
63
|
+
`BlockedEgress`.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnresolvedHost(BlockedEgress):
|
|
68
|
+
"""The host could not be resolved, so nothing is known about where it points.
|
|
69
|
+
|
|
70
|
+
A subclass, so every existing caller keeps failing closed: for the scraper,
|
|
71
|
+
which is handed URLs a model invented, "unknown" is the right thing to
|
|
72
|
+
refuse. A *saved* custom HTTP tool is the other case — the host may be an
|
|
73
|
+
internal API, a service not yet deployed, or a DNS blip, and refusing it is
|
|
74
|
+
a guess about somebody else's network. `core/builder_tools.py` already says
|
|
75
|
+
so in as many words about the same URLs at creation time.
|
|
76
|
+
|
|
77
|
+
Letting it through concedes nothing: httpx resolves through the same stack
|
|
78
|
+
a moment later and fails the same lookup. What is not conceded is an
|
|
79
|
+
address — an IP literal always "resolves", so `169.254.169.254` is still
|
|
80
|
+
refused by the check below.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def allowed_hosts() -> frozenset[str]:
|
|
85
|
+
raw = os.environ.get("EGRESS_ALLOW_HOSTS", "")
|
|
86
|
+
return frozenset(h.strip().lower() for h in raw.split(",") if h.strip())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reject_address(address: str) -> str | None:
|
|
90
|
+
"""Return a reason to refuse this IP, or None to allow it.
|
|
91
|
+
|
|
92
|
+
Grouped by what each range would reach, because the point is not that the
|
|
93
|
+
addresses are unusual but that they are *ours*.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
ip = ipaddress.ip_address(address)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return f"{address} is not an IP address"
|
|
99
|
+
|
|
100
|
+
# An IPv4-mapped IPv6 address (`::ffff:169.254.170.2`) reaches the IPv4
|
|
101
|
+
# host while failing every IPv4 range check, so unwrap before testing.
|
|
102
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
103
|
+
ip = ip.ipv4_mapped
|
|
104
|
+
|
|
105
|
+
if ip.is_loopback:
|
|
106
|
+
return "loopback"
|
|
107
|
+
if ip.is_link_local:
|
|
108
|
+
# 169.254.0.0/16 and fe80::/10 — the cloud metadata range.
|
|
109
|
+
return "link-local, including the cloud metadata endpoint"
|
|
110
|
+
# Before `is_private`, which also covers 0.0.0.0/8 and would report the
|
|
111
|
+
# unspecified address as "private" — true, and unhelpful to whoever typed it.
|
|
112
|
+
if ip.is_unspecified:
|
|
113
|
+
return "the unspecified address"
|
|
114
|
+
if ip.is_private:
|
|
115
|
+
return "private network"
|
|
116
|
+
if ip.is_multicast or ip.is_reserved:
|
|
117
|
+
return "multicast or reserved"
|
|
118
|
+
if ip.version == 4 and ip in _CGNAT:
|
|
119
|
+
return "shared address space"
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def resolve(host: str) -> list[str]:
|
|
124
|
+
"""All addresses `host` resolves to, via the event loop's resolver, cached briefly."""
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
hit = _resolve_cache.get(host)
|
|
127
|
+
if hit is not None and now - hit[0] < _RESOLVE_TTL_SECONDS:
|
|
128
|
+
return hit[1]
|
|
129
|
+
|
|
130
|
+
loop = asyncio.get_running_loop()
|
|
131
|
+
try:
|
|
132
|
+
infos = await loop.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
133
|
+
except socket.gaierror as exc:
|
|
134
|
+
raise UnresolvedHost(f"could not resolve {host}") from exc
|
|
135
|
+
# sockaddr[0] is typed `str | int` because the tuple shape differs by
|
|
136
|
+
# address family; for AF_INET and AF_INET6 it is always the address.
|
|
137
|
+
addresses = sorted({str(info[4][0]) for info in infos})
|
|
138
|
+
|
|
139
|
+
if len(_resolve_cache) >= _RESOLVE_CACHE_MAX:
|
|
140
|
+
_resolve_cache.clear()
|
|
141
|
+
_resolve_cache[host] = (now, addresses)
|
|
142
|
+
return addresses
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def check_host(host: str) -> None:
|
|
146
|
+
"""Raise `BlockedEgress` unless every address behind `host` is public."""
|
|
147
|
+
host = host.strip().lower().strip("[]")
|
|
148
|
+
if not host:
|
|
149
|
+
raise BlockedEgress("that URL has no host")
|
|
150
|
+
if host in allowed_hosts():
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
ipaddress.ip_address(host)
|
|
155
|
+
addresses = [host]
|
|
156
|
+
except ValueError:
|
|
157
|
+
addresses = await resolve(host)
|
|
158
|
+
|
|
159
|
+
for address in addresses:
|
|
160
|
+
reason = reject_address(address)
|
|
161
|
+
if reason is not None:
|
|
162
|
+
# `host` is echoed because the caller supplied it. The addresses
|
|
163
|
+
# it resolved to are not: that is internal topology, and reading
|
|
164
|
+
# it back is exactly what an attacker is probing for.
|
|
165
|
+
raise BlockedEgress(f"{host} resolves to a blocked range: {reason}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def check_tool_url(url: str) -> None:
|
|
169
|
+
"""`check_url`, with the posture a *saved* custom tool needs.
|
|
170
|
+
|
|
171
|
+
The scraper is handed URLs a model invented, so refusing whatever it cannot
|
|
172
|
+
evaluate is right for it. A custom HTTP tool is different: its URL was
|
|
173
|
+
written by a person, saved, and may name an internal API, a service not yet
|
|
174
|
+
deployed, or a host this machine simply cannot resolve. Refusing those is a
|
|
175
|
+
guess about somebody else's network, and it concedes nothing to allow them —
|
|
176
|
+
httpx resolves through the same stack a moment later and fails the same
|
|
177
|
+
lookup, and a string with no scheme is one httpx will not send anywhere.
|
|
178
|
+
|
|
179
|
+
What is *not* conceded is an address: an IP literal always resolves, so the
|
|
180
|
+
cloud metadata endpoint and loopback are refused here exactly as they are
|
|
181
|
+
for the scraper.
|
|
182
|
+
|
|
183
|
+
One function because there are two call sites — the ReAct loop and the
|
|
184
|
+
orchestration TOOL step — and a control written twice is a control that
|
|
185
|
+
drifts. That is the lesson the stdio-MCP gate had to be moved for.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
await check_url(url)
|
|
189
|
+
except (UnresolvedHost, MalformedUrl):
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def check_url(url: str) -> None:
|
|
194
|
+
"""Check one URL, or raise `BlockedEgress`."""
|
|
195
|
+
parsed = urlparse(url)
|
|
196
|
+
if not parsed.scheme:
|
|
197
|
+
raise MalformedUrl("that is not a URL; it has no http:// or https:// scheme")
|
|
198
|
+
if parsed.scheme not in {"http", "https"}:
|
|
199
|
+
raise BlockedEgress(
|
|
200
|
+
f"{parsed.scheme} URLs are not supported; use http or https"
|
|
201
|
+
)
|
|
202
|
+
await check_host(parsed.hostname or "")
|