readyagentsdev 0.8.2__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.
Files changed (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,674 @@
1
+ """Python-native tools that work with zero extra servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import http.client
7
+ import ipaddress
8
+ import json
9
+ import operator
10
+ import os
11
+ import socket
12
+ import ssl
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import ParseResult, urljoin, urlparse
17
+ from uuid import uuid4
18
+
19
+ from readyagents import __version__
20
+ from readyagents.errors import ToolError
21
+ from readyagents.tools import FunctionTool, Tool
22
+
23
+ _BINOPS: dict[type, Any] = {
24
+ ast.Add: operator.add,
25
+ ast.Sub: operator.sub,
26
+ ast.Mult: operator.mul,
27
+ ast.Div: operator.truediv,
28
+ ast.FloorDiv: operator.floordiv,
29
+ ast.Mod: operator.mod,
30
+ ast.Pow: operator.pow,
31
+ }
32
+ _UNARY: dict[type, Any] = {
33
+ ast.UAdd: operator.pos,
34
+ ast.USub: operator.neg,
35
+ }
36
+
37
+ _HTTP_TIMEOUT = 20
38
+ _MAX_HTTP_BYTES = 1_000_000
39
+ _MAX_HTTP_REDIRECTS = 5
40
+ _MAX_POW_EXP = 32
41
+ _MAX_FILE_BYTES = 1_000_000
42
+ _MAX_LIST_DIR_ENTRIES = 500
43
+ _MAX_JSON_BYTES = 1_000_000
44
+ _MAX_CALC_CHARS = 256
45
+ _JSON_REFUSED_SEGMENTS = frozenset({"constructor", "prototype"})
46
+ _BLOCKED_HOST_NAMES = frozenset(
47
+ {
48
+ "localhost",
49
+ "localhost.localdomain",
50
+ "ip6-localhost",
51
+ "ip6-loopback",
52
+ "metadata.google.internal",
53
+ }
54
+ )
55
+
56
+
57
+ def builtin_tools(*, allow_http: bool, workspace: Path) -> list[Tool]:
58
+ workspace = Path(workspace).resolve()
59
+ tools: list[Tool] = [
60
+ FunctionTool(
61
+ name="now",
62
+ description="Current UTC time as ISO-8601.",
63
+ schema={"type": "object", "properties": {}},
64
+ handler=tool_now,
65
+ ),
66
+ FunctionTool(
67
+ name="calc",
68
+ description="Evaluate a safe arithmetic expression (e.g. '2 + 2 * 10').",
69
+ schema={
70
+ "type": "object",
71
+ "properties": {"expression": {"type": "string"}},
72
+ "required": ["expression"],
73
+ },
74
+ handler=tool_calc,
75
+ ),
76
+ FunctionTool(
77
+ name="json_get",
78
+ description="Extract a dotted path from JSON text or an object.",
79
+ schema={
80
+ "type": "object",
81
+ "properties": {
82
+ "data": {},
83
+ "path": {"type": "string"},
84
+ },
85
+ "required": ["data", "path"],
86
+ },
87
+ handler=tool_json_get,
88
+ ),
89
+ FunctionTool(
90
+ name="json_set",
91
+ description="Set a dotted path in JSON text or an object and return the document.",
92
+ schema={
93
+ "type": "object",
94
+ "properties": {
95
+ "data": {},
96
+ "path": {"type": "string"},
97
+ "value": {},
98
+ },
99
+ "required": ["data", "path", "value"],
100
+ },
101
+ handler=tool_json_set,
102
+ ),
103
+ FunctionTool(
104
+ name="json_merge",
105
+ description="Merge a JSON object at a dotted path (empty path merges at the root).",
106
+ schema={
107
+ "type": "object",
108
+ "properties": {
109
+ "data": {},
110
+ "path": {"type": "string"},
111
+ "value": {},
112
+ },
113
+ "required": ["data", "path", "value"],
114
+ },
115
+ handler=tool_json_merge,
116
+ ),
117
+ FunctionTool(
118
+ name="list_dir",
119
+ description="List a directory sandboxed to the workflow workspace (dotfiles skipped).",
120
+ schema={
121
+ "type": "object",
122
+ "properties": {
123
+ "path": {"type": "string"},
124
+ "include_hidden": {"type": "boolean"},
125
+ "max_entries": {"type": "integer"},
126
+ },
127
+ },
128
+ handler=lambda path=".", include_hidden=False, max_entries=200: tool_list_dir(
129
+ path,
130
+ workspace=workspace,
131
+ include_hidden=include_hidden,
132
+ max_entries=max_entries,
133
+ ),
134
+ ),
135
+ FunctionTool(
136
+ name="read_file",
137
+ description="Read a UTF-8 text file sandboxed to the workflow workspace.",
138
+ schema={
139
+ "type": "object",
140
+ "properties": {"path": {"type": "string"}},
141
+ "required": ["path"],
142
+ },
143
+ handler=lambda path: tool_read_file(path, workspace=workspace),
144
+ ),
145
+ FunctionTool(
146
+ name="write_file",
147
+ description="Write a UTF-8 text file sandboxed to the workflow workspace.",
148
+ schema={
149
+ "type": "object",
150
+ "properties": {
151
+ "path": {"type": "string"},
152
+ "content": {"type": "string"},
153
+ },
154
+ "required": ["path", "content"],
155
+ },
156
+ handler=lambda path, content: tool_write_file(path, content, workspace=workspace),
157
+ ),
158
+ FunctionTool(
159
+ name="http_get",
160
+ description="HTTP GET a URL. Disabled unless allow_http is enabled.",
161
+ schema={
162
+ "type": "object",
163
+ "properties": {"url": {"type": "string"}},
164
+ "required": ["url"],
165
+ },
166
+ handler=lambda url: tool_http_get(url, allow_http=allow_http),
167
+ ),
168
+ ]
169
+ return tools
170
+
171
+
172
+ def tool_now() -> str:
173
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
174
+
175
+
176
+ def tool_calc(expression: str | int | float) -> int | float:
177
+ if isinstance(expression, (int, float)):
178
+ return expression
179
+ text = str(expression).strip()
180
+ if not text:
181
+ raise ToolError("calc: empty expression")
182
+ if len(text) > _MAX_CALC_CHARS:
183
+ raise ToolError(f"calc: expression too long (max {_MAX_CALC_CHARS} characters)")
184
+ try:
185
+ tree = ast.parse(text, mode="eval")
186
+ except SyntaxError as exc:
187
+ raise ToolError(f"calc: invalid expression: {exc}") from exc
188
+ return _eval_ast(tree.body)
189
+
190
+
191
+ def _eval_ast(node: ast.AST) -> int | float:
192
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
193
+ if isinstance(node.value, bool):
194
+ raise ToolError("calc: only numbers and + - * / // % ** are allowed")
195
+ return node.value
196
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY:
197
+ return _UNARY[type(node.op)](_eval_ast(node.operand))
198
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
199
+ left = _eval_ast(node.left)
200
+ right = _eval_ast(node.right)
201
+ if isinstance(node.op, ast.Pow) and abs(right) > _MAX_POW_EXP:
202
+ raise ToolError("calc: exponent too large")
203
+ try:
204
+ return _BINOPS[type(node.op)](left, right)
205
+ except ZeroDivisionError as exc:
206
+ raise ToolError("calc: division by zero") from exc
207
+ if isinstance(node, ast.Expr):
208
+ return _eval_ast(node.value)
209
+ raise ToolError("calc: only numbers and + - * / // % ** are allowed")
210
+
211
+
212
+ def tool_json_get(data: Any, path: str) -> Any:
213
+ current: Any = _parse_json_input(data, tool="json_get")
214
+ for part in str(path).split("."):
215
+ if part == "":
216
+ continue
217
+ if isinstance(current, dict):
218
+ if part not in current:
219
+ raise ToolError(f"json_get: path not found: {path}")
220
+ current = current[part]
221
+ continue
222
+ if isinstance(current, list):
223
+ try:
224
+ current = current[int(part)]
225
+ except (ValueError, IndexError) as exc:
226
+ raise ToolError(f"json_get: path not found: {path}") from exc
227
+ continue
228
+ raise ToolError(f"json_get: path not found: {path}")
229
+ return current
230
+
231
+
232
+ def tool_json_set(data: Any, path: str, value: Any) -> Any:
233
+ doc = _json_clone(_parse_json_input(data, tool="json_set"), tool="json_set")
234
+ parsed = _json_clone(_parse_json_value(value, tool="json_set"), tool="json_set")
235
+ parts = _json_path_parts(path, tool="json_set")
236
+ result = _json_assign(doc, parts, parsed, tool="json_set")
237
+ _assert_json_size(result, tool="json_set")
238
+ return result
239
+
240
+
241
+ def tool_json_merge(data: Any, path: str, value: Any) -> Any:
242
+ doc = _json_clone(_parse_json_input(data, tool="json_merge"), tool="json_merge")
243
+ parsed = _json_clone(_parse_json_value(value, tool="json_merge"), tool="json_merge")
244
+ if not isinstance(parsed, dict):
245
+ raise ToolError("json_merge: value must be an object")
246
+ parts = _json_path_parts(path, tool="json_merge", allow_root=True)
247
+ result = _json_merge_at(doc, parts, parsed, tool="json_merge")
248
+ _assert_json_size(result, tool="json_merge")
249
+ return result
250
+
251
+
252
+ def _parse_json_input(data: Any, *, tool: str) -> Any:
253
+ current: Any = data
254
+ if isinstance(current, (bytes, bytearray)):
255
+ if len(current) > _MAX_JSON_BYTES:
256
+ raise ToolError(f"{tool}: data too large (max {_MAX_JSON_BYTES} bytes)")
257
+ current = current.decode("utf-8")
258
+ if isinstance(current, str):
259
+ if len(current.encode("utf-8")) > _MAX_JSON_BYTES:
260
+ raise ToolError(f"{tool}: data too large (max {_MAX_JSON_BYTES} bytes)")
261
+ text = current.strip()
262
+ if text:
263
+ try:
264
+ current = json.loads(text)
265
+ except json.JSONDecodeError as exc:
266
+ raise ToolError(f"{tool}: data is not valid JSON: {exc}") from exc
267
+ return current
268
+
269
+
270
+ def _parse_json_value(value: Any, *, tool: str) -> Any:
271
+ current: Any = value
272
+ if isinstance(current, (bytes, bytearray)):
273
+ if len(current) > _MAX_JSON_BYTES:
274
+ raise ToolError(f"{tool}: value too large (max {_MAX_JSON_BYTES} bytes)")
275
+ current = current.decode("utf-8")
276
+ if isinstance(current, str):
277
+ if len(current.encode("utf-8")) > _MAX_JSON_BYTES:
278
+ raise ToolError(f"{tool}: value too large (max {_MAX_JSON_BYTES} bytes)")
279
+ text = current.strip()
280
+ if text:
281
+ try:
282
+ return json.loads(text)
283
+ except json.JSONDecodeError:
284
+ return current
285
+ return current
286
+
287
+
288
+ def _json_clone(data: Any, *, tool: str) -> Any:
289
+ try:
290
+ return json.loads(json.dumps(data, ensure_ascii=False))
291
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
292
+ raise ToolError(f"{tool}: data is not valid JSON: {exc}") from exc
293
+
294
+
295
+ def _assert_json_size(data: Any, *, tool: str) -> None:
296
+ try:
297
+ blob = json.dumps(data, ensure_ascii=False)
298
+ except (TypeError, ValueError) as exc:
299
+ raise ToolError(f"{tool}: result is not valid JSON: {exc}") from exc
300
+ if len(blob.encode("utf-8")) > _MAX_JSON_BYTES:
301
+ raise ToolError(f"{tool}: result too large (max {_MAX_JSON_BYTES} bytes)")
302
+
303
+
304
+ def _json_path_parts(path: str, *, tool: str, allow_root: bool = False) -> list[str]:
305
+ text = str(path)
306
+ if allow_root and text in {"", "."}:
307
+ return []
308
+ parts = text.split(".")
309
+ for part in parts:
310
+ if part == "":
311
+ raise ToolError(f"{tool}: empty path segment")
312
+ if part.startswith("__") or part in _JSON_REFUSED_SEGMENTS:
313
+ raise ToolError(f"{tool}: refused path segment: {part}")
314
+ return parts
315
+
316
+
317
+ def _json_assign(doc: Any, parts: list[str], value: Any, *, tool: str) -> Any:
318
+ if not parts:
319
+ raise ToolError(f"{tool}: path is required")
320
+ if not isinstance(doc, (dict, list)):
321
+ raise ToolError(f"{tool}: data must be an object or array")
322
+ parent = _json_walk_parent(doc, parts[:-1], tool=tool)
323
+ _json_put(parent, parts[-1], value, tool=tool)
324
+ return doc
325
+
326
+
327
+ def _json_merge_at(doc: Any, parts: list[str], value: dict[str, Any], *, tool: str) -> Any:
328
+ if not parts:
329
+ if not isinstance(doc, dict):
330
+ raise ToolError(f"{tool}: target is not an object")
331
+ doc.update(value)
332
+ return doc
333
+ if not isinstance(doc, (dict, list)):
334
+ raise ToolError(f"{tool}: data must be an object or array")
335
+ parent = _json_walk_parent(doc, parts[:-1], tool=tool)
336
+ target = _json_child_object(parent, parts[-1], tool=tool)
337
+ target.update(value)
338
+ return doc
339
+
340
+
341
+ def _json_walk_parent(doc: Any, parts: list[str], *, tool: str) -> Any:
342
+ current = doc
343
+ for part in parts:
344
+ current = _json_ensure_container(current, part, tool=tool)
345
+ return current
346
+
347
+
348
+ def _json_ensure_container(current: Any, part: str, *, tool: str) -> Any:
349
+ if isinstance(current, dict):
350
+ child = current.get(part, None)
351
+ if child is None:
352
+ current[part] = {}
353
+ return current[part]
354
+ if not isinstance(child, (dict, list)):
355
+ raise ToolError(f"{tool}: cannot descend into non-container at {part}")
356
+ return child
357
+ if isinstance(current, list):
358
+ idx = _json_list_index(current, part, tool=tool)
359
+ child = current[idx]
360
+ if child is None:
361
+ current[idx] = {}
362
+ return current[idx]
363
+ if not isinstance(child, (dict, list)):
364
+ raise ToolError(f"{tool}: cannot descend into non-container at {part}")
365
+ return child
366
+ raise ToolError(f"{tool}: cannot descend into non-container at {part}")
367
+
368
+
369
+ def _json_put(parent: Any, part: str, value: Any, *, tool: str) -> None:
370
+ if isinstance(parent, dict):
371
+ parent[part] = value
372
+ return
373
+ if isinstance(parent, list):
374
+ parent[_json_list_index(parent, part, tool=tool)] = value
375
+ return
376
+ raise ToolError(f"{tool}: cannot set path")
377
+
378
+
379
+ def _json_child_object(parent: Any, part: str, *, tool: str) -> dict[str, Any]:
380
+ if isinstance(parent, dict):
381
+ child = parent.get(part, None)
382
+ if child is None:
383
+ parent[part] = {}
384
+ return parent[part]
385
+ if not isinstance(child, dict):
386
+ raise ToolError(f"{tool}: target is not an object")
387
+ return child
388
+ if isinstance(parent, list):
389
+ idx = _json_list_index(parent, part, tool=tool)
390
+ child = parent[idx]
391
+ if child is None:
392
+ parent[idx] = {}
393
+ return parent[idx]
394
+ if not isinstance(child, dict):
395
+ raise ToolError(f"{tool}: target is not an object")
396
+ return child
397
+ raise ToolError(f"{tool}: target is not an object")
398
+
399
+
400
+ def _json_list_index(seq: list[Any], part: str, *, tool: str) -> int:
401
+ try:
402
+ idx = int(part)
403
+ seq[idx]
404
+ except (ValueError, IndexError) as exc:
405
+ raise ToolError(f"{tool}: path not found: {part}") from exc
406
+ return idx
407
+
408
+
409
+ def tool_http_get(url: str, *, allow_http: bool) -> str:
410
+ if not allow_http:
411
+ raise ToolError(
412
+ "http_get is disabled. Set READYAGENTS_ALLOW_HTTP=1 or set allow_http: true "
413
+ "on the workflow if you intend to fetch URLs."
414
+ )
415
+ current = url.strip() if isinstance(url, str) else ""
416
+ for _ in range(_MAX_HTTP_REDIRECTS + 1):
417
+ parsed = _assert_public_http_url(current)
418
+ assert parsed.hostname is not None
419
+ ips = _resolve_public_ips(parsed.hostname)
420
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
421
+ path = parsed.path or "/"
422
+ if parsed.query:
423
+ path = f"{path}?{parsed.query}"
424
+ last_err: Exception | None = None
425
+ status = 0
426
+ body = b""
427
+ location: str | None = None
428
+ for ip in ips:
429
+ try:
430
+ status, body, location = _http_exchange(
431
+ parsed.scheme, parsed.hostname, ip, port, path
432
+ )
433
+ last_err = None
434
+ break
435
+ except (TimeoutError, OSError) as exc:
436
+ last_err = exc
437
+ if last_err is not None:
438
+ raise ToolError(f"http_get failed: {last_err}") from last_err
439
+ if status in {301, 302, 303, 307, 308} and location:
440
+ current = urljoin(current, location)
441
+ continue
442
+ if status >= 400:
443
+ raise ToolError(f"http_get failed: HTTP Error {status}:")
444
+ if len(body) > _MAX_HTTP_BYTES:
445
+ raise ToolError("http_get: response too large")
446
+ return body.decode("utf-8", errors="replace")
447
+ raise ToolError("http_get: too many redirects")
448
+
449
+
450
+ def _http_exchange(
451
+ scheme: str,
452
+ hostname: str,
453
+ ip: str,
454
+ port: int,
455
+ path: str,
456
+ *,
457
+ method: str = "GET",
458
+ body: bytes | None = None,
459
+ headers: dict[str, str] | None = None,
460
+ timeout: float | None = None,
461
+ ) -> tuple[int, bytes, str | None]:
462
+ timeout = _HTTP_TIMEOUT if timeout is None else timeout
463
+ req_headers = {"User-Agent": f"readyagents/{__version__}"}
464
+ if headers:
465
+ req_headers.update(headers)
466
+ if scheme == "https":
467
+ ctx = ssl.create_default_context()
468
+ conn: http.client.HTTPConnection = http.client.HTTPSConnection(
469
+ hostname, port, timeout=timeout, context=ctx
470
+ )
471
+
472
+ def connect() -> None:
473
+ sock = socket.create_connection((ip, port), timeout)
474
+ conn.sock = ctx.wrap_socket(sock, server_hostname=hostname)
475
+
476
+ conn.connect = connect # type: ignore[method-assign]
477
+ else:
478
+ conn = http.client.HTTPConnection(hostname, port, timeout=timeout)
479
+
480
+ def connect() -> None:
481
+ conn.sock = socket.create_connection((ip, port), timeout)
482
+
483
+ conn.connect = connect # type: ignore[method-assign]
484
+ try:
485
+ if body is None:
486
+ conn.request(method, path, headers=req_headers)
487
+ else:
488
+ conn.request(method, path, body=body, headers=req_headers)
489
+ resp = conn.getresponse()
490
+ payload = resp.read(_MAX_HTTP_BYTES + 1)
491
+ return resp.status, payload, resp.getheader("Location")
492
+ finally:
493
+ conn.close()
494
+
495
+
496
+ def _assert_public_http_url(url: object, *, kind: str = "http_get") -> ParseResult:
497
+ if not isinstance(url, str) or not url.strip():
498
+ raise ToolError(f"{kind}: url must start with http:// or https://")
499
+ parsed = urlparse(url.strip())
500
+ if parsed.scheme not in {"http", "https"}:
501
+ raise ToolError(f"{kind}: url must start with http:// or https://")
502
+ if parsed.username is not None or parsed.password is not None:
503
+ raise ToolError(f"{kind}: URLs with userinfo are not allowed")
504
+ if not parsed.hostname:
505
+ raise ToolError(f"{kind}: URL must include a host")
506
+ return parsed
507
+
508
+
509
+ def _resolve_public_ips(host: str, *, kind: str = "http_get") -> list[str]:
510
+ name = host.strip().lower().rstrip(".")
511
+ not_allowed = (
512
+ f"{kind}: host '{host}' is not allowed "
513
+ "(loopback, private, link-local, or metadata addresses)"
514
+ )
515
+ if name in _BLOCKED_HOST_NAMES or name.endswith(".localhost") or name.endswith(".local"):
516
+ raise ToolError(not_allowed)
517
+ try:
518
+ ip = ipaddress.ip_address(name)
519
+ except ValueError:
520
+ ip = None
521
+ if ip is not None:
522
+ if _ip_is_blocked(ip):
523
+ raise ToolError(not_allowed)
524
+ return [str(ip)]
525
+ try:
526
+ infos = socket.getaddrinfo(name, None, type=socket.SOCK_STREAM)
527
+ except socket.gaierror as exc:
528
+ raise ToolError(f"{kind}: could not resolve host '{host}'") from exc
529
+ if not infos:
530
+ raise ToolError(f"{kind}: could not resolve host '{host}'")
531
+ ips: list[str] = []
532
+ seen: set[str] = set()
533
+ for info in infos:
534
+ addr = info[4][0]
535
+ try:
536
+ parsed_ip = ipaddress.ip_address(addr)
537
+ except ValueError:
538
+ continue
539
+ if _ip_is_blocked(parsed_ip):
540
+ raise ToolError(not_allowed)
541
+ text = str(parsed_ip)
542
+ if text not in seen:
543
+ seen.add(text)
544
+ ips.append(text)
545
+ if not ips:
546
+ raise ToolError(f"{kind}: could not resolve host '{host}'")
547
+ return ips
548
+
549
+
550
+ def _ip_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
551
+ if ip.version == 6 and ip.ipv4_mapped is not None:
552
+ ip = ip.ipv4_mapped
553
+ return bool(
554
+ ip.is_private
555
+ or ip.is_loopback
556
+ or ip.is_link_local
557
+ or ip.is_multicast
558
+ or ip.is_reserved
559
+ or ip.is_unspecified
560
+ or not ip.is_global
561
+ )
562
+
563
+
564
+ def tool_list_dir(
565
+ path: str = ".",
566
+ *,
567
+ workspace: Path,
568
+ include_hidden: bool = False,
569
+ max_entries: int = 200,
570
+ ) -> list[dict[str, Any]]:
571
+ """List a workspace directory. Dotfiles skipped unless include_hidden."""
572
+ target = _sandbox_dir(path, workspace)
573
+ try:
574
+ cap = int(max_entries)
575
+ except (TypeError, ValueError) as extra:
576
+ raise ToolError(f"list_dir: max_entries must be an integer: {extra}") from extra
577
+ if cap < 1:
578
+ raise ToolError("list_dir: max_entries must be >= 1")
579
+ cap = min(cap, _MAX_LIST_DIR_ENTRIES)
580
+ hidden = bool(include_hidden)
581
+ rows: list[dict[str, Any]] = []
582
+ try:
583
+ names = sorted(target.iterdir(), key=lambda p: p.name.lower())
584
+ except OSError as extra:
585
+ raise ToolError(f"list_dir: could not list {path}: {extra}") from extra
586
+ for child in names:
587
+ if not hidden and child.name.startswith("."):
588
+ continue
589
+ try:
590
+ resolved_child = child.resolve()
591
+ if not resolved_child.is_relative_to(workspace):
592
+ continue
593
+ is_dir = resolved_child.is_dir()
594
+ is_file = resolved_child.is_file()
595
+ size = resolved_child.stat().st_size if is_file else 0
596
+ except OSError:
597
+ continue
598
+ kind = "dir" if is_dir else "file" if is_file else "other"
599
+ rows.append({"name": child.name, "type": kind, "size": int(size)})
600
+ if len(rows) >= cap:
601
+ break
602
+ return rows
603
+
604
+
605
+ def _sandbox_dir(path: str, workspace: Path) -> Path:
606
+ """Resolve a directory path inside workspace (workspace root is allowed)."""
607
+ workspace = Path(workspace).resolve()
608
+ text = str(path).strip() or "."
609
+ if "\x00" in text:
610
+ raise ToolError("Path contains invalid characters")
611
+ if text == "..":
612
+ raise ToolError("Path must stay inside the workspace")
613
+ candidate = Path(text)
614
+ if not candidate.is_absolute():
615
+ candidate = workspace / candidate
616
+ resolved = candidate.resolve()
617
+ if not resolved.is_relative_to(workspace):
618
+ raise ToolError(f"Path '{path}' is outside the workspace")
619
+ if not resolved.is_dir():
620
+ raise ToolError(f"list_dir: not a directory: {path}")
621
+ return resolved
622
+
623
+
624
+ def tool_read_file(path: str, *, workspace: Path) -> str:
625
+ target = _sandbox_path(path, workspace)
626
+ if not target.is_file():
627
+ raise ToolError(f"read_file: not a file: {path}")
628
+ try:
629
+ size = target.stat().st_size
630
+ if size > _MAX_FILE_BYTES:
631
+ raise ToolError(f"read_file: file too large ({size} bytes, max {_MAX_FILE_BYTES})")
632
+ return target.read_text(encoding="utf-8")
633
+ except ToolError:
634
+ raise
635
+ except (OSError, UnicodeError) as exc:
636
+ raise ToolError(f"read_file: could not read {path}: {exc}") from exc
637
+
638
+
639
+ def tool_write_file(path: str, content: str, *, workspace: Path) -> str:
640
+ target = _sandbox_path(path, workspace)
641
+ if target.exists() and not target.is_file():
642
+ raise ToolError(f"write_file: not a file: {path}")
643
+ payload = str(content)
644
+ size = len(payload.encode("utf-8"))
645
+ if size > _MAX_FILE_BYTES:
646
+ raise ToolError(f"write_file: content too large ({size} bytes, max {_MAX_FILE_BYTES})")
647
+ try:
648
+ target.parent.mkdir(parents=True, exist_ok=True)
649
+ tmp = target.parent / f".{target.name}.{uuid4().hex}.tmp"
650
+ try:
651
+ tmp.write_text(payload, encoding="utf-8")
652
+ os.replace(tmp, target)
653
+ finally:
654
+ if tmp.exists():
655
+ tmp.unlink(missing_ok=True)
656
+ except OSError as exc:
657
+ raise ToolError(f"write_file: could not write {path}: {exc}") from exc
658
+ return str(target)
659
+
660
+
661
+ def _sandbox_path(path: str, workspace: Path) -> Path:
662
+ workspace = Path(workspace).resolve()
663
+ text = str(path).strip()
664
+ if not text or text in {".", ".."}:
665
+ raise ToolError("Path must be a file inside the workspace")
666
+ if "\x00" in text:
667
+ raise ToolError("Path contains invalid characters")
668
+ candidate = Path(text)
669
+ if not candidate.is_absolute():
670
+ candidate = workspace / candidate
671
+ resolved = candidate.resolve()
672
+ if not resolved.is_relative_to(workspace) or resolved == workspace:
673
+ raise ToolError(f"Path '{path}' is outside the workspace")
674
+ return resolved