synapse-orch-ai 1.9.1 → 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.
Files changed (136) hide show
  1. package/backend/core/egress_guard.py +202 -0
  2. package/backend/core/expressions.py +522 -0
  3. package/backend/core/file_roots.py +80 -0
  4. package/backend/core/internal_auth.py +69 -14
  5. package/backend/core/models.py +20 -2
  6. package/backend/core/orchestration/steps.py +38 -75
  7. package/backend/core/react_engine.py +8 -0
  8. package/backend/core/routes/settings.py +5 -1
  9. package/backend/core/routes/tools.py +14 -4
  10. package/backend/core/safe_fetch.py +106 -0
  11. package/backend/core/server.py +20 -25
  12. package/backend/tools/code_search.py +23 -21
  13. package/backend/tools/file_reader.py +12 -2
  14. package/backend/tools/pdf_parser.py +5 -5
  15. package/backend/tools/sandbox.py +6 -2
  16. package/backend/tools/web_scraper.py +80 -0
  17. package/backend/tools/xlsx_parser.py +5 -5
  18. package/frontend-build/.next/BUILD_ID +1 -1
  19. package/frontend-build/.next/build-manifest.json +3 -3
  20. package/frontend-build/.next/prerender-manifest.json +3 -3
  21. package/frontend-build/.next/server/app/_global-error.html +1 -1
  22. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  23. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  24. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  25. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  29. package/frontend-build/.next/server/app/_not-found.html +1 -1
  30. package/frontend-build/.next/server/app/_not-found.rsc +2 -2
  31. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  32. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  33. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  34. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  35. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  36. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  37. package/frontend-build/.next/server/app/api/agent-types/route.js +3 -2
  38. package/frontend-build/.next/server/app/api/agent-types/route.js.nft.json +1 -1
  39. package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js +3 -2
  40. package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js.nft.json +1 -1
  41. package/frontend-build/.next/server/app/api/auth/login/route.js +3 -2
  42. package/frontend-build/.next/server/app/api/auth/login/route.js.nft.json +1 -1
  43. package/frontend-build/.next/server/app/api/auth/logout/route.js +2 -1
  44. package/frontend-build/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  45. package/frontend-build/.next/server/app/api/auth/status/route.js +3 -2
  46. package/frontend-build/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  47. package/frontend-build/.next/server/app/api/builder/chat/route.js +3 -2
  48. package/frontend-build/.next/server/app/api/builder/chat/route.js.nft.json +1 -1
  49. package/frontend-build/.next/server/app/api/builder/resume/route.js +3 -2
  50. package/frontend-build/.next/server/app/api/builder/resume/route.js.nft.json +1 -1
  51. package/frontend-build/.next/server/app/api/chat/route.js +3 -2
  52. package/frontend-build/.next/server/app/api/chat/route.js.nft.json +1 -1
  53. package/frontend-build/.next/server/app/api/chat/stream/route.js +3 -2
  54. package/frontend-build/.next/server/app/api/chat/stream/route.js.nft.json +1 -1
  55. package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js +3 -2
  56. package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js.nft.json +1 -1
  57. package/frontend-build/.next/server/app/api/logs/[type]/route.js +3 -2
  58. package/frontend-build/.next/server/app/api/logs/[type]/route.js.nft.json +1 -1
  59. package/frontend-build/.next/server/app/api/models/route.js +3 -2
  60. package/frontend-build/.next/server/app/api/models/route.js.nft.json +1 -1
  61. package/frontend-build/.next/server/app/api/notifications/stream/route.js +3 -2
  62. package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -1
  63. package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js +3 -2
  64. package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js.nft.json +1 -1
  65. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +3 -2
  66. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -1
  67. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js +3 -2
  68. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js.nft.json +1 -1
  69. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js +3 -2
  70. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js.nft.json +1 -1
  71. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js +3 -2
  72. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js.nft.json +1 -1
  73. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js +3 -2
  74. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js.nft.json +1 -1
  75. package/frontend-build/.next/server/app/api/schedules/route.js +3 -2
  76. package/frontend-build/.next/server/app/api/schedules/route.js.nft.json +1 -1
  77. package/frontend-build/.next/server/app/icon.png/route.js +2 -1
  78. package/frontend-build/.next/server/app/icon.png/route.js.nft.json +1 -1
  79. package/frontend-build/.next/server/app/index.html +1 -1
  80. package/frontend-build/.next/server/app/index.rsc +2 -2
  81. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  82. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
  83. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  84. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
  85. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  86. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  87. package/frontend-build/.next/server/app/login.html +1 -1
  88. package/frontend-build/.next/server/app/login.rsc +2 -2
  89. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
  90. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  91. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
  92. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  93. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  94. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  95. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  96. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  97. package/frontend-build/.next/server/chunks/[root-of-the-server]__0.-gycy._.js +3 -0
  98. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0~bybkp._.js → [root-of-the-server]__003vgom._.js} +2 -2
  99. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t4a96i._.js → [root-of-the-server]__03ek9xq._.js} +2 -2
  100. package/frontend-build/.next/server/chunks/{[root-of-the-server]__07vf6z1._.js → [root-of-the-server]__03x0ii~._.js} +2 -2
  101. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0wqyl-j._.js → [root-of-the-server]__03zuar1._.js} +2 -2
  102. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0zl78ks._.js → [root-of-the-server]__04ks5f_._.js} +2 -2
  103. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t.cymd._.js → [root-of-the-server]__0_fafcz._.js} +2 -2
  104. package/frontend-build/.next/server/chunks/[root-of-the-server]__0_~_1-l._.js +10 -0
  105. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0fa0k5_._.js → [root-of-the-server]__0dbuutb._.js} +2 -2
  106. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0hnd6qu._.js → [root-of-the-server]__0hlm8c7._.js} +2 -2
  107. package/frontend-build/.next/server/chunks/[root-of-the-server]__0is-hlj._.js +3 -0
  108. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0n-ayat._.js → [root-of-the-server]__0i~uoki._.js} +2 -2
  109. package/frontend-build/.next/server/chunks/[root-of-the-server]__0js9sik._.js +3 -0
  110. package/frontend-build/.next/server/chunks/[root-of-the-server]__0ncun1_._.js +1 -1
  111. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0i0g0o9._.js → [root-of-the-server]__0oe4zjm._.js} +2 -2
  112. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0av_50r._.js → [root-of-the-server]__0v4clie._.js} +2 -2
  113. package/frontend-build/.next/server/chunks/[root-of-the-server]__0w02yhg._.js +3 -0
  114. package/frontend-build/.next/server/chunks/[root-of-the-server]__0wbbjuq._.js +3 -0
  115. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0-0m2_n._.js → [root-of-the-server]__0wgri4w._.js} +2 -2
  116. package/frontend-build/.next/server/chunks/[root-of-the-server]__0xtvg9h._.js +3 -0
  117. package/frontend-build/.next/server/chunks/{[root-of-the-server]__01abhvg._.js → [root-of-the-server]__0yuz2ug._.js} +2 -2
  118. package/frontend-build/.next/server/chunks/node_modules_next_124cnn1._.js +3 -0
  119. package/frontend-build/.next/server/chunks/node_modules_next_dist_12gp6q5._.js +6 -0
  120. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  121. package/frontend-build/.next/server/pages/404.html +1 -1
  122. package/frontend-build/.next/server/pages/500.html +1 -1
  123. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  124. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  125. package/frontend-build/.next/static/chunks/{0a-yeaiijslpf.css → 13upr_rmz7sy8.css} +5 -5
  126. package/package.json +1 -1
  127. package/frontend-build/.next/server/chunks/[root-of-the-server]__08ei_wz._.js +0 -3
  128. package/frontend-build/.next/server/chunks/[root-of-the-server]__0_qd3xa._.js +0 -3
  129. package/frontend-build/.next/server/chunks/[root-of-the-server]__0aw6-s5._.js +0 -3
  130. package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +0 -3
  131. package/frontend-build/.next/server/chunks/[root-of-the-server]__0j8-xkl._.js +0 -13
  132. package/frontend-build/.next/server/chunks/[root-of-the-server]__0uulfit._.js +0 -3
  133. package/frontend-build/.next/server/chunks/[root-of-the-server]__10fof-m._.js +0 -3
  134. /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → YrERDQ96GLlwEG7rNI1lz}/_buildManifest.js +0 -0
  135. /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → YrERDQ96GLlwEG7rNI1lz}/_clientMiddlewareManifest.js +0 -0
  136. /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → YrERDQ96GLlwEG7rNI1lz}/_ssgManifest.js +0 -0
@@ -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 "")