synapse-orch-ai 1.9.1 → 1.9.3

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.
Files changed (138) hide show
  1. package/backend/core/browser_automation.py +91 -0
  2. package/backend/core/egress_guard.py +223 -0
  3. package/backend/core/egress_proxy.py +229 -0
  4. package/backend/core/expressions.py +522 -0
  5. package/backend/core/file_roots.py +80 -0
  6. package/backend/core/internal_auth.py +69 -14
  7. package/backend/core/models.py +20 -2
  8. package/backend/core/orchestration/steps.py +38 -75
  9. package/backend/core/react_engine.py +8 -0
  10. package/backend/core/routes/settings.py +5 -1
  11. package/backend/core/routes/tools.py +14 -4
  12. package/backend/core/safe_fetch.py +106 -0
  13. package/backend/core/server.py +67 -34
  14. package/backend/tools/code_search.py +23 -21
  15. package/backend/tools/file_reader.py +12 -2
  16. package/backend/tools/pdf_parser.py +5 -5
  17. package/backend/tools/sandbox.py +6 -2
  18. package/backend/tools/web_scraper.py +172 -28
  19. package/backend/tools/xlsx_parser.py +5 -5
  20. package/frontend-build/.next/BUILD_ID +1 -1
  21. package/frontend-build/.next/build-manifest.json +3 -3
  22. package/frontend-build/.next/prerender-manifest.json +3 -3
  23. package/frontend-build/.next/server/app/_global-error.html +1 -1
  24. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  25. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  26. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  28. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  29. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  30. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  31. package/frontend-build/.next/server/app/_not-found.html +1 -1
  32. package/frontend-build/.next/server/app/_not-found.rsc +2 -2
  33. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  34. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  35. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  36. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  37. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  38. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  39. package/frontend-build/.next/server/app/api/agent-types/route.js +3 -2
  40. package/frontend-build/.next/server/app/api/agent-types/route.js.nft.json +1 -1
  41. package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js +3 -2
  42. package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js.nft.json +1 -1
  43. package/frontend-build/.next/server/app/api/auth/login/route.js +3 -2
  44. package/frontend-build/.next/server/app/api/auth/login/route.js.nft.json +1 -1
  45. package/frontend-build/.next/server/app/api/auth/logout/route.js +2 -1
  46. package/frontend-build/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  47. package/frontend-build/.next/server/app/api/auth/status/route.js +3 -2
  48. package/frontend-build/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  49. package/frontend-build/.next/server/app/api/builder/chat/route.js +3 -2
  50. package/frontend-build/.next/server/app/api/builder/chat/route.js.nft.json +1 -1
  51. package/frontend-build/.next/server/app/api/builder/resume/route.js +3 -2
  52. package/frontend-build/.next/server/app/api/builder/resume/route.js.nft.json +1 -1
  53. package/frontend-build/.next/server/app/api/chat/route.js +3 -2
  54. package/frontend-build/.next/server/app/api/chat/route.js.nft.json +1 -1
  55. package/frontend-build/.next/server/app/api/chat/stream/route.js +3 -2
  56. package/frontend-build/.next/server/app/api/chat/stream/route.js.nft.json +1 -1
  57. package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js +3 -2
  58. package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js.nft.json +1 -1
  59. package/frontend-build/.next/server/app/api/logs/[type]/route.js +3 -2
  60. package/frontend-build/.next/server/app/api/logs/[type]/route.js.nft.json +1 -1
  61. package/frontend-build/.next/server/app/api/models/route.js +3 -2
  62. package/frontend-build/.next/server/app/api/models/route.js.nft.json +1 -1
  63. package/frontend-build/.next/server/app/api/notifications/stream/route.js +3 -2
  64. package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -1
  65. package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js +3 -2
  66. package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js.nft.json +1 -1
  67. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +3 -2
  68. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -1
  69. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js +3 -2
  70. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js.nft.json +1 -1
  71. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js +3 -2
  72. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js.nft.json +1 -1
  73. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js +3 -2
  74. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js.nft.json +1 -1
  75. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js +3 -2
  76. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js.nft.json +1 -1
  77. package/frontend-build/.next/server/app/api/schedules/route.js +3 -2
  78. package/frontend-build/.next/server/app/api/schedules/route.js.nft.json +1 -1
  79. package/frontend-build/.next/server/app/icon.png/route.js +2 -1
  80. package/frontend-build/.next/server/app/icon.png/route.js.nft.json +1 -1
  81. package/frontend-build/.next/server/app/index.html +1 -1
  82. package/frontend-build/.next/server/app/index.rsc +2 -2
  83. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  84. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
  85. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  86. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
  87. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  88. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  89. package/frontend-build/.next/server/app/login.html +1 -1
  90. package/frontend-build/.next/server/app/login.rsc +2 -2
  91. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
  92. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  93. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
  94. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  95. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  96. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  97. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  98. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  99. package/frontend-build/.next/server/chunks/[root-of-the-server]__0.-gycy._.js +3 -0
  100. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0~bybkp._.js → [root-of-the-server]__003vgom._.js} +2 -2
  101. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t4a96i._.js → [root-of-the-server]__03ek9xq._.js} +2 -2
  102. package/frontend-build/.next/server/chunks/{[root-of-the-server]__07vf6z1._.js → [root-of-the-server]__03x0ii~._.js} +2 -2
  103. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0wqyl-j._.js → [root-of-the-server]__03zuar1._.js} +2 -2
  104. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0zl78ks._.js → [root-of-the-server]__04ks5f_._.js} +2 -2
  105. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t.cymd._.js → [root-of-the-server]__0_fafcz._.js} +2 -2
  106. package/frontend-build/.next/server/chunks/[root-of-the-server]__0_~_1-l._.js +10 -0
  107. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0fa0k5_._.js → [root-of-the-server]__0dbuutb._.js} +2 -2
  108. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0hnd6qu._.js → [root-of-the-server]__0hlm8c7._.js} +2 -2
  109. package/frontend-build/.next/server/chunks/[root-of-the-server]__0is-hlj._.js +3 -0
  110. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0n-ayat._.js → [root-of-the-server]__0i~uoki._.js} +2 -2
  111. package/frontend-build/.next/server/chunks/[root-of-the-server]__0js9sik._.js +3 -0
  112. package/frontend-build/.next/server/chunks/[root-of-the-server]__0ncun1_._.js +1 -1
  113. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0i0g0o9._.js → [root-of-the-server]__0oe4zjm._.js} +2 -2
  114. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0av_50r._.js → [root-of-the-server]__0v4clie._.js} +2 -2
  115. package/frontend-build/.next/server/chunks/[root-of-the-server]__0w02yhg._.js +3 -0
  116. package/frontend-build/.next/server/chunks/[root-of-the-server]__0wbbjuq._.js +3 -0
  117. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0-0m2_n._.js → [root-of-the-server]__0wgri4w._.js} +2 -2
  118. package/frontend-build/.next/server/chunks/[root-of-the-server]__0xtvg9h._.js +3 -0
  119. package/frontend-build/.next/server/chunks/{[root-of-the-server]__01abhvg._.js → [root-of-the-server]__0yuz2ug._.js} +2 -2
  120. package/frontend-build/.next/server/chunks/node_modules_next_124cnn1._.js +3 -0
  121. package/frontend-build/.next/server/chunks/node_modules_next_dist_12gp6q5._.js +6 -0
  122. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  123. package/frontend-build/.next/server/pages/404.html +1 -1
  124. package/frontend-build/.next/server/pages/500.html +1 -1
  125. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  126. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  127. package/frontend-build/.next/static/chunks/{0a-yeaiijslpf.css → 13upr_rmz7sy8.css} +5 -5
  128. package/package.json +1 -1
  129. package/frontend-build/.next/server/chunks/[root-of-the-server]__08ei_wz._.js +0 -3
  130. package/frontend-build/.next/server/chunks/[root-of-the-server]__0_qd3xa._.js +0 -3
  131. package/frontend-build/.next/server/chunks/[root-of-the-server]__0aw6-s5._.js +0 -3
  132. package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +0 -3
  133. package/frontend-build/.next/server/chunks/[root-of-the-server]__0j8-xkl._.js +0 -13
  134. package/frontend-build/.next/server/chunks/[root-of-the-server]__0uulfit._.js +0 -3
  135. package/frontend-build/.next/server/chunks/[root-of-the-server]__10fof-m._.js +0 -3
  136. /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → OIEoX5QA1J1xrSoahxwNa}/_buildManifest.js +0 -0
  137. /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → OIEoX5QA1J1xrSoahxwNa}/_clientMiddlewareManifest.js +0 -0
  138. /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → OIEoX5QA1J1xrSoahxwNa}/_ssgManifest.js +0 -0
@@ -0,0 +1,91 @@
1
+ """What the Browser Automation server may be asked to do.
2
+
3
+ The server is Playwright's MCP server (`@playwright/mcp`), started by `core/server.py`, on by default, and called
4
+ by agents that read web pages, so what a page says can steer it. Its tools are offered through `gated()`:
5
+
6
+ * **Only the tools reviewed for the pinned version.** None that run code in the server's own process
7
+ (`browser_run_code_unsafe`; Playwright's own description calls it RCE-equivalent), run a model's script in the
8
+ page (`browser_evaluate`), or read local files into a page (`browser_file_upload`, `browser_drop`). A tool a
9
+ later version adds is not offered until someone reviews it and adds it here.
10
+ * **A `url` is http(s)**, or `about:blank`: never `file:`, `chrome:`, `data:` and the like.
11
+ * **A `filename` stays in the server's folder**: relative, no `..`. The server is started with that folder as its
12
+ working directory and output directory, so it is where its own files land.
13
+
14
+ Where the browser may connect is not decided here: its network goes through the egress proxy
15
+ (`core.egress_proxy`). BRW1's browser component replaces this server and its gate (`core/browser/gate.py`).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from pathlib import PurePosixPath, PureWindowsPath
20
+ from typing import Any
21
+ from urllib.parse import urlsplit
22
+
23
+ #: The server's name in `core/server.py`'s list, and the package it runs, pinned: `npx -y @playwright/mcp` ran
24
+ #: whatever was newest the first time, and a new version's new tools reached agents unreviewed.
25
+ BROWSER_AUTOMATION = "Browser Automation"
26
+ PLAYWRIGHT_MCP = "@playwright/mcp@0.0.83"
27
+
28
+ #: The tools of the pinned version that are offered. Reviewed 2026-10-28 against what 0.0.83 lists.
29
+ OFFERED = frozenset({
30
+ "browser_navigate", "browser_navigate_back", "browser_tabs", "browser_snapshot", "browser_find",
31
+ "browser_wait_for", "browser_take_screenshot", "browser_console_messages", "browser_network_requests",
32
+ "browser_network_request", "browser_click", "browser_type", "browser_fill_form", "browser_select_option",
33
+ "browser_press_key", "browser_hover", "browser_drag", "browser_handle_dialog", "browser_resize",
34
+ "browser_emulate_media", "browser_close",
35
+ })
36
+
37
+ _URL_ARGS = ("url",)
38
+ _FILE_ARGS = ("filename",)
39
+
40
+
41
+ def refusal(name: str, arguments: dict | None) -> str | None:
42
+ """Why this call is not made, or None when it may be."""
43
+ if name not in OFFERED:
44
+ return f"{name} is not available in Synapse's browser."
45
+ args = arguments or {}
46
+ for key in _URL_ARGS:
47
+ url = args.get(key)
48
+ if url is None:
49
+ continue
50
+ scheme = urlsplit(str(url)).scheme.lower()
51
+ if scheme not in ("http", "https") and str(url) != "about:blank":
52
+ return f"{name} opens http and https addresses only."
53
+ for key in _FILE_ARGS:
54
+ filename = args.get(key)
55
+ if filename is None:
56
+ continue
57
+ text = str(filename)
58
+ if (text.startswith(("/", "\\", "~")) or PureWindowsPath(text).drive
59
+ or ".." in PurePosixPath(text.replace("\\", "/")).parts):
60
+ return f"{name} saves only inside its own folder: give a file name, not a path."
61
+ return None
62
+
63
+
64
+ class _Gated:
65
+ """The server's session, offering only `OFFERED` and refusing what `refusal` refuses. Anything else (a ping,
66
+ initialize) is the session's own."""
67
+
68
+ def __init__(self, session: Any) -> None:
69
+ self._session = session
70
+
71
+ def __getattr__(self, name: str) -> Any:
72
+ return getattr(self._session, name)
73
+
74
+ async def list_tools(self, *args: Any, **kwargs: Any) -> Any:
75
+ result = await self._session.list_tools(*args, **kwargs)
76
+ return result.model_copy(update={"tools": [t for t in result.tools if t.name in OFFERED]})
77
+
78
+ async def call_tool(self, name: str, arguments: dict | None = None, *args: Any, **kwargs: Any) -> Any:
79
+ reason = refusal(name, arguments)
80
+ if reason is not None:
81
+ import mcp.types as types
82
+
83
+ return types.CallToolResult(content=[types.TextContent(type="text", text=reason)], isError=True)
84
+ return await self._session.call_tool(name, arguments, *args, **kwargs)
85
+
86
+
87
+ def gated(server_name: str, session: Any) -> Any:
88
+ """`session`, gated when it is the Browser Automation server's; any other server's as it is."""
89
+ if server_name != BROWSER_AUTOMATION or isinstance(session, _Gated):
90
+ return session
91
+ return _Gated(session)
@@ -0,0 +1,223 @@
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
+ _check_addresses(host, await _addresses(host))
153
+
154
+
155
+ async def checked_addresses(host: str) -> list[str]:
156
+ """The addresses behind `host`, every one of them public, or raise `BlockedEgress`.
157
+
158
+ For a caller that opens the connection itself (`core.egress_proxy`): it dials
159
+ one of these and never the name again, so the address it checked is the
160
+ address it reaches. A host in `EGRESS_ALLOW_HOSTS` is resolved, not checked.
161
+ """
162
+ host = host.strip().lower().strip("[]")
163
+ if not host:
164
+ raise BlockedEgress("that URL has no host")
165
+ addresses = await _addresses(host)
166
+ if host not in allowed_hosts():
167
+ _check_addresses(host, addresses)
168
+ return addresses
169
+
170
+
171
+ async def _addresses(host: str) -> list[str]:
172
+ try:
173
+ ipaddress.ip_address(host)
174
+ return [host]
175
+ except ValueError:
176
+ return await resolve(host)
177
+
178
+
179
+ def _check_addresses(host: str, addresses: list[str]) -> None:
180
+ for address in addresses:
181
+ reason = reject_address(address)
182
+ if reason is not None:
183
+ # `host` is echoed because the caller supplied it. The addresses
184
+ # it resolved to are not: that is internal topology, and reading
185
+ # it back is exactly what an attacker is probing for.
186
+ raise BlockedEgress(f"{host} resolves to a blocked range: {reason}")
187
+
188
+
189
+ async def check_tool_url(url: str) -> None:
190
+ """`check_url`, with the posture a *saved* custom tool needs.
191
+
192
+ The scraper is handed URLs a model invented, so refusing whatever it cannot
193
+ evaluate is right for it. A custom HTTP tool is different: its URL was
194
+ written by a person, saved, and may name an internal API, a service not yet
195
+ deployed, or a host this machine simply cannot resolve. Refusing those is a
196
+ guess about somebody else's network, and it concedes nothing to allow them —
197
+ httpx resolves through the same stack a moment later and fails the same
198
+ lookup, and a string with no scheme is one httpx will not send anywhere.
199
+
200
+ What is *not* conceded is an address: an IP literal always resolves, so the
201
+ cloud metadata endpoint and loopback are refused here exactly as they are
202
+ for the scraper.
203
+
204
+ One function because there are two call sites — the ReAct loop and the
205
+ orchestration TOOL step — and a control written twice is a control that
206
+ drifts. That is the lesson the stdio-MCP gate had to be moved for.
207
+ """
208
+ try:
209
+ await check_url(url)
210
+ except (UnresolvedHost, MalformedUrl):
211
+ return
212
+
213
+
214
+ async def check_url(url: str) -> None:
215
+ """Check one URL, or raise `BlockedEgress`."""
216
+ parsed = urlparse(url)
217
+ if not parsed.scheme:
218
+ raise MalformedUrl("that is not a URL; it has no http:// or https:// scheme")
219
+ if parsed.scheme not in {"http", "https"}:
220
+ raise BlockedEgress(
221
+ f"{parsed.scheme} URLs are not supported; use http or https"
222
+ )
223
+ await check_host(parsed.hostname or "")
@@ -0,0 +1,229 @@
1
+ """One egress proxy for every browser Synapse starts.
2
+
3
+ `core/egress_guard.py` decides where a fetch may go. A browser makes its own
4
+ requests — redirects, a page's scripts, in-page fetches, WebSockets, service
5
+ workers — and a hook inside the browser does not see every one of them. A
6
+ proxy does: the browser is started with this one as its proxy, Playwright puts
7
+ loopback through it too (`<-loopback>` in Chromium's bypass list), and it opens
8
+ no connection the guard refuses.
9
+
10
+ It also closes the window the guard leaves open on purpose. The guard resolves
11
+ a name, and the fetch it guards resolves it again; here the address that was
12
+ checked (`egress_guard.checked_addresses`) is the address dialled, and the name
13
+ is never looked up a second time.
14
+
15
+ What it speaks is the least a browser needs: `CONNECT host:port` for TLS and
16
+ WebSockets, and an absolute-form `http://` request for plain HTTP, sent on as
17
+ one request per connection (`Connection: close`) so a second request on the
18
+ same connection cannot name another host. A refusal is a `403` whose body is
19
+ the guard's message (it names the host asked for, never an address it
20
+ resolved to); anything that is not a proxy request is a `400`.
21
+
22
+ It listens on loopback and needs no credentials: what it reaches is the public
23
+ internet, which anything on the machine reaches already.
24
+
25
+ Deliberately stdlib-only, like the guard: the scraper's subprocess starts its own.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import sys
31
+ from urllib.parse import urlsplit
32
+
33
+ from core import egress_guard
34
+
35
+ #: What a Chromium behind this proxy is started with. UDP never goes through a proxy: WebRTC takes the proxied path
36
+ #: or none, and QUIC, which WebTransport rides with no route in its way, is off.
37
+ CHROMIUM_ARGS = ("--force-webrtc-ip-handling-policy=disable_non_proxied_udp", "--disable-quic")
38
+
39
+ #: A request head larger than this is not one a browser sends.
40
+ _HEAD_LIMIT = 64 * 1024
41
+ _HEAD_TIMEOUT_SECONDS = 30.0
42
+ CONNECT_TIMEOUT_SECONDS = 10.0
43
+ #: After the browser closes its side, how long the other side has to finish.
44
+ _DRAIN_SECONDS = 30.0
45
+
46
+ _REASONS = {400: "Bad Request", 403: "Forbidden", 502: "Bad Gateway"}
47
+
48
+ #: Headers meant for the proxy itself, never sent on.
49
+ _FOR_THE_PROXY = {"proxy-connection", "proxy-authorization", "proxy-authenticate"}
50
+
51
+
52
+ class _BadRequest(ValueError):
53
+ pass
54
+
55
+
56
+ class _Request:
57
+ def __init__(self, head: bytes):
58
+ lines = head[:-4].decode("latin-1").split("\r\n")
59
+ parts = lines[0].split(" ")
60
+ if len(parts) != 3 or not parts[2].startswith("HTTP/"):
61
+ raise _BadRequest("not an HTTP request")
62
+ self.method, target, version = parts
63
+ self.tunnel = self.method.upper() == "CONNECT"
64
+ try:
65
+ if self.tunnel:
66
+ url = urlsplit(f"//{target}")
67
+ self.host, self.port = url.hostname, url.port
68
+ if not self.host or not self.port:
69
+ raise _BadRequest("CONNECT needs host:port")
70
+ self.forward = b""
71
+ return
72
+ url = urlsplit(target)
73
+ self.host, self.port = url.hostname, url.port or 80
74
+ except ValueError as exc:
75
+ raise _BadRequest(f"not an address: {exc}") from None
76
+ if url.scheme != "http" or not self.host:
77
+ raise _BadRequest("a proxy request is CONNECT host:port or an absolute http:// URL")
78
+
79
+ headers = []
80
+ for line in lines[1:]:
81
+ name, _, value = line.partition(":")
82
+ if name.strip().lower() not in _FOR_THE_PROXY:
83
+ headers.append((name.strip(), value.strip()))
84
+ connection = {v.strip().lower() for n, value in headers if n.lower() == "connection"
85
+ for v in value.split(",")}
86
+ upgrade = "upgrade" in connection and any(n.lower() == "upgrade" for n, _ in headers)
87
+ if not upgrade:
88
+ headers = [(n, v) for n, v in headers if n.lower() not in ("connection", "keep-alive")]
89
+ headers.append(("Connection", "close"))
90
+ path = (url.path or "/") + (f"?{url.query}" if url.query else "")
91
+ self.forward = "".join([f"{self.method} {path} {version}\r\n",
92
+ *(f"{n}: {v}\r\n" for n, v in headers), "\r\n"]).encode("latin-1")
93
+
94
+
95
+ class EgressProxy:
96
+ """A loopback proxy that dials only what `core.egress_guard` allows. `start()` once, `close()` when done."""
97
+
98
+ host = "127.0.0.1"
99
+
100
+ def __init__(self) -> None:
101
+ self.port = 0
102
+ self.loop: asyncio.AbstractEventLoop | None = None
103
+ self._server: asyncio.base_events.Server | None = None
104
+ self._starting: asyncio.Future | None = None
105
+ self._writers: set[asyncio.StreamWriter] = set()
106
+ self._closed = False
107
+
108
+ @property
109
+ def url(self) -> str:
110
+ return f"http://{self.host}:{self.port}"
111
+
112
+ @property
113
+ def closed(self) -> bool:
114
+ return self._closed
115
+
116
+ async def start(self) -> str:
117
+ """Listen, once; every caller waits for the same start."""
118
+ if self._starting is None:
119
+ self.loop = asyncio.get_running_loop()
120
+ self._starting = asyncio.ensure_future(self._listen())
121
+ await asyncio.shield(self._starting)
122
+ return self.url
123
+
124
+ async def _listen(self) -> None:
125
+ self._server = await asyncio.start_server(self._serve, self.host, 0, limit=_HEAD_LIMIT)
126
+ self.port = self._server.sockets[0].getsockname()[1]
127
+
128
+ async def close(self) -> None:
129
+ self._closed = True
130
+ if self._server is not None:
131
+ self._server.close()
132
+ for writer in list(self._writers):
133
+ writer.close()
134
+ if self._server is not None:
135
+ await self._server.wait_closed()
136
+
137
+ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
138
+ self._writers.add(writer)
139
+ upstream: asyncio.StreamWriter | None = None
140
+ host = ""
141
+ try:
142
+ try:
143
+ head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), _HEAD_TIMEOUT_SECONDS)
144
+ except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError, ConnectionError):
145
+ return
146
+ request = _Request(head)
147
+ host = request.host
148
+ addresses = await egress_guard.checked_addresses(host)
149
+ up_reader, upstream = await _dial(addresses, request.port)
150
+ self._writers.add(upstream)
151
+ if request.tunnel:
152
+ writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
153
+ else:
154
+ upstream.write(request.forward)
155
+ await _relay(reader, writer, up_reader, upstream)
156
+ except _BadRequest as exc:
157
+ _answer(writer, 400, str(exc))
158
+ except egress_guard.BlockedEgress as exc:
159
+ print(f"egress proxy: refused {host}: {exc}", file=sys.stderr)
160
+ _answer(writer, 403, str(exc))
161
+ except (OSError, TimeoutError):
162
+ _answer(writer, 502, f"could not reach {host}")
163
+ finally:
164
+ for w in (upstream, writer):
165
+ if w is not None:
166
+ self._writers.discard(w)
167
+ w.close()
168
+
169
+
170
+ async def _dial(addresses: list[str], port: int):
171
+ """A connection to the first of `addresses` that answers. Never the name: the addresses are what was checked."""
172
+ last: BaseException = OSError("no address")
173
+ for address in addresses:
174
+ try:
175
+ return await asyncio.wait_for(asyncio.open_connection(address, port), CONNECT_TIMEOUT_SECONDS)
176
+ except (OSError, TimeoutError) as exc:
177
+ last = exc
178
+ raise last
179
+
180
+
181
+ async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
182
+ try:
183
+ while data := await src.read(65536):
184
+ dst.write(data)
185
+ await dst.drain()
186
+ except ConnectionError:
187
+ pass
188
+
189
+
190
+ async def _relay(client_r, client_w, up_r, up_w) -> None:
191
+ """Both ways until the far side is done. The browser closing its side gives the far side a moment to finish."""
192
+ outbound = asyncio.ensure_future(_pipe(client_r, up_w))
193
+ inbound = asyncio.ensure_future(_pipe(up_r, client_w))
194
+ try:
195
+ done, _ = await asyncio.wait({outbound, inbound}, return_when=asyncio.FIRST_COMPLETED)
196
+ if inbound not in done:
197
+ if up_w.can_write_eof():
198
+ try:
199
+ up_w.write_eof()
200
+ except OSError:
201
+ pass
202
+ await asyncio.wait({inbound}, timeout=_DRAIN_SECONDS)
203
+ finally:
204
+ for task in (outbound, inbound):
205
+ task.cancel()
206
+
207
+
208
+ def _answer(writer: asyncio.StreamWriter, status: int, reason: str) -> None:
209
+ body = f"{reason}\n".encode()
210
+ try:
211
+ writer.write(f"HTTP/1.1 {status} {_REASONS[status]}\r\nContent-Type: text/plain; charset=utf-8\r\n"
212
+ f"Content-Length: {len(body)}\r\nX-Synapse-Egress: refused\r\nConnection: close\r\n\r\n"
213
+ .encode() + body)
214
+ except (OSError, RuntimeError):
215
+ pass
216
+
217
+
218
+ _shared: EgressProxy | None = None
219
+
220
+
221
+ async def shared() -> EgressProxy:
222
+ """This event loop's proxy, started on first use. A closed one, or one from another loop, is replaced."""
223
+ global _shared
224
+ loop = asyncio.get_running_loop()
225
+ proxy = _shared
226
+ if proxy is None or proxy.closed or (proxy.loop is not None and proxy.loop is not loop):
227
+ proxy = _shared = EgressProxy()
228
+ await proxy.start()
229
+ return proxy