smartify-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smartify/__init__.py +3 -0
- smartify/agents/__init__.py +0 -0
- smartify/agents/adapters/__init__.py +13 -0
- smartify/agents/adapters/anthropic.py +253 -0
- smartify/agents/adapters/openai.py +289 -0
- smartify/api/__init__.py +26 -0
- smartify/api/auth.py +352 -0
- smartify/api/errors.py +380 -0
- smartify/api/events.py +345 -0
- smartify/api/server.py +992 -0
- smartify/cli/__init__.py +1 -0
- smartify/cli/main.py +430 -0
- smartify/engine/__init__.py +64 -0
- smartify/engine/approval.py +479 -0
- smartify/engine/orchestrator.py +1365 -0
- smartify/engine/scheduler.py +380 -0
- smartify/engine/spark.py +294 -0
- smartify/guardrails/__init__.py +22 -0
- smartify/guardrails/breakers.py +409 -0
- smartify/models/__init__.py +61 -0
- smartify/models/grid.py +625 -0
- smartify/notifications/__init__.py +22 -0
- smartify/notifications/webhook.py +556 -0
- smartify/state/__init__.py +46 -0
- smartify/state/checkpoint.py +558 -0
- smartify/state/resume.py +301 -0
- smartify/state/store.py +370 -0
- smartify/tools/__init__.py +17 -0
- smartify/tools/base.py +196 -0
- smartify/tools/builtin/__init__.py +79 -0
- smartify/tools/builtin/file.py +464 -0
- smartify/tools/builtin/http.py +195 -0
- smartify/tools/builtin/shell.py +137 -0
- smartify/tools/mcp/__init__.py +33 -0
- smartify/tools/mcp/adapter.py +157 -0
- smartify/tools/mcp/client.py +334 -0
- smartify/tools/mcp/registry.py +130 -0
- smartify/validator/__init__.py +0 -0
- smartify/validator/validate.py +271 -0
- smartify/workspace/__init__.py +5 -0
- smartify/workspace/manager.py +248 -0
- smartify_ai-0.1.0.dist-info/METADATA +201 -0
- smartify_ai-0.1.0.dist-info/RECORD +46 -0
- smartify_ai-0.1.0.dist-info/WHEEL +4 -0
- smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
- smartify_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""File operation tools."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from smartify.tools.base import Tool, ToolResult
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileReadTool(Tool):
|
|
14
|
+
"""Read file contents."""
|
|
15
|
+
|
|
16
|
+
name = "file_read"
|
|
17
|
+
description = "Read the contents of a file. Returns the file content as text."
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_path: Optional[str] = None,
|
|
22
|
+
max_size_bytes: int = 10 * 1024 * 1024, # 10MB
|
|
23
|
+
):
|
|
24
|
+
"""Initialize file read tool.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
base_path: If set, all paths are relative to this base
|
|
28
|
+
max_size_bytes: Maximum file size to read
|
|
29
|
+
"""
|
|
30
|
+
self.base_path = Path(base_path).resolve() if base_path else None
|
|
31
|
+
self.max_size_bytes = max_size_bytes
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def parameters(self) -> Dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"path": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Path to the file to read"
|
|
41
|
+
},
|
|
42
|
+
"encoding": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "File encoding (default: utf-8)"
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"required": ["path"]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def _resolve_path(self, path: str) -> Path:
|
|
51
|
+
"""Resolve and validate path."""
|
|
52
|
+
p = Path(path)
|
|
53
|
+
if self.base_path:
|
|
54
|
+
if p.is_absolute():
|
|
55
|
+
return p.resolve()
|
|
56
|
+
else:
|
|
57
|
+
return (self.base_path / p).resolve()
|
|
58
|
+
return p.resolve()
|
|
59
|
+
|
|
60
|
+
def _check_path_escape(self, resolved: Path) -> bool:
|
|
61
|
+
"""Check if resolved path escapes base_path. Returns True if escape detected."""
|
|
62
|
+
if not self.base_path:
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
resolved.relative_to(self.base_path)
|
|
66
|
+
return False
|
|
67
|
+
except ValueError:
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
async def execute(
|
|
71
|
+
self,
|
|
72
|
+
path: str,
|
|
73
|
+
encoding: str = "utf-8",
|
|
74
|
+
**kwargs
|
|
75
|
+
) -> ToolResult:
|
|
76
|
+
"""Read a file.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: File path
|
|
80
|
+
encoding: Text encoding
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
ToolResult with file contents
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
resolved = self._resolve_path(path)
|
|
87
|
+
|
|
88
|
+
# Security: check path doesn't escape base_path
|
|
89
|
+
if self._check_path_escape(resolved):
|
|
90
|
+
return ToolResult(
|
|
91
|
+
success=False,
|
|
92
|
+
error=f"Path escapes allowed directory: {path}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if not resolved.exists():
|
|
96
|
+
return ToolResult(
|
|
97
|
+
success=False,
|
|
98
|
+
error=f"File not found: {path}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if not resolved.is_file():
|
|
102
|
+
return ToolResult(
|
|
103
|
+
success=False,
|
|
104
|
+
error=f"Not a file: {path}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Check size
|
|
108
|
+
size = resolved.stat().st_size
|
|
109
|
+
if size > self.max_size_bytes:
|
|
110
|
+
return ToolResult(
|
|
111
|
+
success=False,
|
|
112
|
+
error=f"File too large: {size} bytes (max: {self.max_size_bytes})"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
content = resolved.read_text(encoding=encoding)
|
|
116
|
+
|
|
117
|
+
return ToolResult(
|
|
118
|
+
success=True,
|
|
119
|
+
output=content,
|
|
120
|
+
metadata={
|
|
121
|
+
"path": str(resolved),
|
|
122
|
+
"size": size,
|
|
123
|
+
"encoding": encoding,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
except UnicodeDecodeError as e:
|
|
128
|
+
return ToolResult(
|
|
129
|
+
success=False,
|
|
130
|
+
error=f"Failed to decode file with encoding {encoding}: {e}"
|
|
131
|
+
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return ToolResult(
|
|
134
|
+
success=False,
|
|
135
|
+
error=f"Failed to read file: {str(e)}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class FileWriteTool(Tool):
|
|
140
|
+
"""Write content to a file."""
|
|
141
|
+
|
|
142
|
+
name = "file_write"
|
|
143
|
+
description = "Write content to a file. Creates the file if it doesn't exist, or overwrites if it does."
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
base_path: Optional[str] = None,
|
|
148
|
+
create_dirs: bool = True,
|
|
149
|
+
):
|
|
150
|
+
"""Initialize file write tool.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
base_path: If set, all paths are relative to this base
|
|
154
|
+
create_dirs: Create parent directories if they don't exist
|
|
155
|
+
"""
|
|
156
|
+
self.base_path = Path(base_path).resolve() if base_path else None
|
|
157
|
+
self.create_dirs = create_dirs
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def parameters(self) -> Dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"type": "object",
|
|
163
|
+
"properties": {
|
|
164
|
+
"path": {
|
|
165
|
+
"type": "string",
|
|
166
|
+
"description": "Path to the file to write"
|
|
167
|
+
},
|
|
168
|
+
"content": {
|
|
169
|
+
"type": "string",
|
|
170
|
+
"description": "Content to write to the file"
|
|
171
|
+
},
|
|
172
|
+
"append": {
|
|
173
|
+
"type": "boolean",
|
|
174
|
+
"description": "Append to file instead of overwriting (default: false)"
|
|
175
|
+
},
|
|
176
|
+
"encoding": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"description": "File encoding (default: utf-8)"
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"required": ["path", "content"]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def _resolve_path(self, path: str) -> Path:
|
|
185
|
+
"""Resolve and validate path."""
|
|
186
|
+
p = Path(path)
|
|
187
|
+
if self.base_path:
|
|
188
|
+
if p.is_absolute():
|
|
189
|
+
return p.resolve()
|
|
190
|
+
else:
|
|
191
|
+
return (self.base_path / p).resolve()
|
|
192
|
+
return p.resolve()
|
|
193
|
+
|
|
194
|
+
def _check_path_escape(self, resolved: Path) -> bool:
|
|
195
|
+
"""Check if resolved path escapes base_path."""
|
|
196
|
+
if not self.base_path:
|
|
197
|
+
return False
|
|
198
|
+
try:
|
|
199
|
+
resolved.relative_to(self.base_path)
|
|
200
|
+
return False
|
|
201
|
+
except ValueError:
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
async def execute(
|
|
205
|
+
self,
|
|
206
|
+
path: str,
|
|
207
|
+
content: str,
|
|
208
|
+
append: bool = False,
|
|
209
|
+
encoding: str = "utf-8",
|
|
210
|
+
**kwargs
|
|
211
|
+
) -> ToolResult:
|
|
212
|
+
"""Write to a file.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
path: File path
|
|
216
|
+
content: Content to write
|
|
217
|
+
append: Append instead of overwrite
|
|
218
|
+
encoding: Text encoding
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
ToolResult with write status
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
resolved = self._resolve_path(path)
|
|
225
|
+
|
|
226
|
+
# Security: check path doesn't escape base_path
|
|
227
|
+
if self._check_path_escape(resolved):
|
|
228
|
+
return ToolResult(
|
|
229
|
+
success=False,
|
|
230
|
+
error=f"Path escapes allowed directory: {path}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Create parent directories
|
|
234
|
+
if self.create_dirs:
|
|
235
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
|
|
237
|
+
mode = "a" if append else "w"
|
|
238
|
+
with open(resolved, mode, encoding=encoding) as f:
|
|
239
|
+
f.write(content)
|
|
240
|
+
|
|
241
|
+
size = resolved.stat().st_size
|
|
242
|
+
|
|
243
|
+
return ToolResult(
|
|
244
|
+
success=True,
|
|
245
|
+
output={
|
|
246
|
+
"path": str(resolved),
|
|
247
|
+
"size": size,
|
|
248
|
+
"mode": "appended" if append else "written",
|
|
249
|
+
},
|
|
250
|
+
metadata={"path": str(resolved)}
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
except Exception as e:
|
|
254
|
+
return ToolResult(
|
|
255
|
+
success=False,
|
|
256
|
+
error=f"Failed to write file: {str(e)}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class FileListTool(Tool):
|
|
261
|
+
"""List files in a directory."""
|
|
262
|
+
|
|
263
|
+
name = "file_list"
|
|
264
|
+
description = "List files and directories in a path. Returns names and metadata."
|
|
265
|
+
|
|
266
|
+
def __init__(self, base_path: Optional[str] = None):
|
|
267
|
+
self.base_path = Path(base_path).resolve() if base_path else None
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def parameters(self) -> Dict[str, Any]:
|
|
271
|
+
return {
|
|
272
|
+
"type": "object",
|
|
273
|
+
"properties": {
|
|
274
|
+
"path": {
|
|
275
|
+
"type": "string",
|
|
276
|
+
"description": "Directory path to list (default: current directory)"
|
|
277
|
+
},
|
|
278
|
+
"pattern": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"description": "Glob pattern to filter files (e.g., '*.py')"
|
|
281
|
+
},
|
|
282
|
+
"recursive": {
|
|
283
|
+
"type": "boolean",
|
|
284
|
+
"description": "List recursively (default: false)"
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
"required": []
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def _resolve_path(self, path: str) -> Path:
|
|
291
|
+
p = Path(path) if path else Path(".")
|
|
292
|
+
if self.base_path:
|
|
293
|
+
if p.is_absolute():
|
|
294
|
+
return p.resolve()
|
|
295
|
+
else:
|
|
296
|
+
return (self.base_path / p).resolve()
|
|
297
|
+
return p.resolve()
|
|
298
|
+
|
|
299
|
+
async def execute(
|
|
300
|
+
self,
|
|
301
|
+
path: str = ".",
|
|
302
|
+
pattern: Optional[str] = None,
|
|
303
|
+
recursive: bool = False,
|
|
304
|
+
**kwargs
|
|
305
|
+
) -> ToolResult:
|
|
306
|
+
"""List directory contents.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
path: Directory path
|
|
310
|
+
pattern: Glob pattern
|
|
311
|
+
recursive: List recursively
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
ToolResult with file listing
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
resolved = self._resolve_path(path)
|
|
318
|
+
|
|
319
|
+
if not resolved.exists():
|
|
320
|
+
return ToolResult(
|
|
321
|
+
success=False,
|
|
322
|
+
error=f"Path not found: {path}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if not resolved.is_dir():
|
|
326
|
+
return ToolResult(
|
|
327
|
+
success=False,
|
|
328
|
+
error=f"Not a directory: {path}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Get entries
|
|
332
|
+
if pattern:
|
|
333
|
+
if recursive:
|
|
334
|
+
entries = list(resolved.rglob(pattern))
|
|
335
|
+
else:
|
|
336
|
+
entries = list(resolved.glob(pattern))
|
|
337
|
+
else:
|
|
338
|
+
if recursive:
|
|
339
|
+
entries = list(resolved.rglob("*"))
|
|
340
|
+
else:
|
|
341
|
+
entries = list(resolved.iterdir())
|
|
342
|
+
|
|
343
|
+
# Build result
|
|
344
|
+
files = []
|
|
345
|
+
for entry in sorted(entries):
|
|
346
|
+
try:
|
|
347
|
+
stat = entry.stat()
|
|
348
|
+
files.append({
|
|
349
|
+
"name": entry.name,
|
|
350
|
+
"path": str(entry.relative_to(resolved) if not recursive else entry),
|
|
351
|
+
"type": "dir" if entry.is_dir() else "file",
|
|
352
|
+
"size": stat.st_size if entry.is_file() else None,
|
|
353
|
+
})
|
|
354
|
+
except (OSError, ValueError):
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
return ToolResult(
|
|
358
|
+
success=True,
|
|
359
|
+
output={
|
|
360
|
+
"path": str(resolved),
|
|
361
|
+
"count": len(files),
|
|
362
|
+
"entries": files,
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
except Exception as e:
|
|
367
|
+
return ToolResult(
|
|
368
|
+
success=False,
|
|
369
|
+
error=f"Failed to list directory: {str(e)}"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class FileDeleteTool(Tool):
|
|
374
|
+
"""Delete a file."""
|
|
375
|
+
|
|
376
|
+
name = "file_delete"
|
|
377
|
+
description = "Delete a file. Use with caution."
|
|
378
|
+
|
|
379
|
+
def __init__(
|
|
380
|
+
self,
|
|
381
|
+
base_path: Optional[str] = None,
|
|
382
|
+
allow_directories: bool = False,
|
|
383
|
+
):
|
|
384
|
+
self.base_path = Path(base_path).resolve() if base_path else None
|
|
385
|
+
self.allow_directories = allow_directories
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def parameters(self) -> Dict[str, Any]:
|
|
389
|
+
return {
|
|
390
|
+
"type": "object",
|
|
391
|
+
"properties": {
|
|
392
|
+
"path": {
|
|
393
|
+
"type": "string",
|
|
394
|
+
"description": "Path to the file to delete"
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
"required": ["path"]
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
def _resolve_path(self, path: str) -> Path:
|
|
401
|
+
p = Path(path)
|
|
402
|
+
if self.base_path:
|
|
403
|
+
if p.is_absolute():
|
|
404
|
+
return p.resolve()
|
|
405
|
+
else:
|
|
406
|
+
return (self.base_path / p).resolve()
|
|
407
|
+
return p.resolve()
|
|
408
|
+
|
|
409
|
+
def _check_path_escape(self, resolved: Path) -> bool:
|
|
410
|
+
"""Check if resolved path escapes base_path."""
|
|
411
|
+
if not self.base_path:
|
|
412
|
+
return False
|
|
413
|
+
try:
|
|
414
|
+
resolved.relative_to(self.base_path)
|
|
415
|
+
return False
|
|
416
|
+
except ValueError:
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
async def execute(self, path: str, **kwargs) -> ToolResult:
|
|
420
|
+
"""Delete a file.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
path: File path to delete
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
ToolResult with deletion status
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
resolved = self._resolve_path(path)
|
|
430
|
+
|
|
431
|
+
# Security: check path doesn't escape base_path
|
|
432
|
+
if self._check_path_escape(resolved):
|
|
433
|
+
return ToolResult(
|
|
434
|
+
success=False,
|
|
435
|
+
error=f"Path escapes allowed directory: {path}"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if not resolved.exists():
|
|
439
|
+
return ToolResult(
|
|
440
|
+
success=False,
|
|
441
|
+
error=f"File not found: {path}"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if resolved.is_dir():
|
|
445
|
+
if not self.allow_directories:
|
|
446
|
+
return ToolResult(
|
|
447
|
+
success=False,
|
|
448
|
+
error="Directory deletion not allowed"
|
|
449
|
+
)
|
|
450
|
+
import shutil
|
|
451
|
+
shutil.rmtree(resolved)
|
|
452
|
+
else:
|
|
453
|
+
resolved.unlink()
|
|
454
|
+
|
|
455
|
+
return ToolResult(
|
|
456
|
+
success=True,
|
|
457
|
+
output={"deleted": str(resolved)},
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
except Exception as e:
|
|
461
|
+
return ToolResult(
|
|
462
|
+
success=False,
|
|
463
|
+
error=f"Failed to delete: {str(e)}"
|
|
464
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""HTTP request tool."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from smartify.tools.base import Tool, ToolResult
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HttpTool(Tool):
|
|
14
|
+
"""Make HTTP requests.
|
|
15
|
+
|
|
16
|
+
Supports GET, POST, PUT, PATCH, DELETE with JSON or form data.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name = "http"
|
|
20
|
+
description = "Make an HTTP request to a URL. Supports GET, POST, PUT, PATCH, DELETE methods with JSON or form data."
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
timeout: float = 30.0,
|
|
25
|
+
max_response_size: int = 5 * 1024 * 1024, # 5MB
|
|
26
|
+
allowed_hosts: Optional[List[str]] = None,
|
|
27
|
+
blocked_hosts: Optional[List[str]] = None,
|
|
28
|
+
):
|
|
29
|
+
"""Initialize HTTP tool.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
timeout: Request timeout in seconds
|
|
33
|
+
max_response_size: Maximum response size to read
|
|
34
|
+
allowed_hosts: If set, only these hosts are allowed
|
|
35
|
+
blocked_hosts: Hosts to block (e.g., internal IPs)
|
|
36
|
+
"""
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.max_response_size = max_response_size
|
|
39
|
+
self.allowed_hosts = allowed_hosts
|
|
40
|
+
self.blocked_hosts = blocked_hosts or [
|
|
41
|
+
"localhost",
|
|
42
|
+
"127.0.0.1",
|
|
43
|
+
"0.0.0.0",
|
|
44
|
+
"169.254.", # Link-local
|
|
45
|
+
"10.", # Private
|
|
46
|
+
"172.16.", # Private
|
|
47
|
+
"192.168.", # Private
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def parameters(self) -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {
|
|
55
|
+
"url": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "The URL to request"
|
|
58
|
+
},
|
|
59
|
+
"method": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
62
|
+
"description": "HTTP method (default: GET)"
|
|
63
|
+
},
|
|
64
|
+
"headers": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"description": "HTTP headers as key-value pairs"
|
|
67
|
+
},
|
|
68
|
+
"json": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"description": "JSON body for POST/PUT/PATCH"
|
|
71
|
+
},
|
|
72
|
+
"data": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"description": "Form data for POST/PUT/PATCH"
|
|
75
|
+
},
|
|
76
|
+
"params": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"description": "URL query parameters"
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"required": ["url"]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def _check_host(self, url: str) -> Optional[str]:
|
|
85
|
+
"""Check if host is allowed. Returns error message if blocked."""
|
|
86
|
+
try:
|
|
87
|
+
from urllib.parse import urlparse
|
|
88
|
+
parsed = urlparse(url)
|
|
89
|
+
host = parsed.hostname or ""
|
|
90
|
+
|
|
91
|
+
# Check blocked hosts
|
|
92
|
+
for blocked in self.blocked_hosts:
|
|
93
|
+
if host.startswith(blocked) or host == blocked:
|
|
94
|
+
return f"Host blocked: {host}"
|
|
95
|
+
|
|
96
|
+
# Check allowed hosts
|
|
97
|
+
if self.allowed_hosts:
|
|
98
|
+
if host not in self.allowed_hosts:
|
|
99
|
+
return f"Host not in allowlist: {host}"
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
except Exception as e:
|
|
103
|
+
return f"Invalid URL: {e}"
|
|
104
|
+
|
|
105
|
+
async def execute(
|
|
106
|
+
self,
|
|
107
|
+
url: str,
|
|
108
|
+
method: str = "GET",
|
|
109
|
+
headers: Optional[Dict[str, str]] = None,
|
|
110
|
+
json: Optional[Dict[str, Any]] = None,
|
|
111
|
+
data: Optional[Dict[str, Any]] = None,
|
|
112
|
+
params: Optional[Dict[str, str]] = None,
|
|
113
|
+
**kwargs
|
|
114
|
+
) -> ToolResult:
|
|
115
|
+
"""Make an HTTP request.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
url: Request URL
|
|
119
|
+
method: HTTP method
|
|
120
|
+
headers: Request headers
|
|
121
|
+
json: JSON body
|
|
122
|
+
data: Form data
|
|
123
|
+
params: Query parameters
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ToolResult with response data
|
|
127
|
+
"""
|
|
128
|
+
# Security check
|
|
129
|
+
host_error = self._check_host(url)
|
|
130
|
+
if host_error:
|
|
131
|
+
return ToolResult(
|
|
132
|
+
success=False,
|
|
133
|
+
error=host_error
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
method = method.upper()
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
140
|
+
response = await client.request(
|
|
141
|
+
method=method,
|
|
142
|
+
url=url,
|
|
143
|
+
headers=headers,
|
|
144
|
+
json=json,
|
|
145
|
+
data=data,
|
|
146
|
+
params=params,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Check response size
|
|
150
|
+
content_length = response.headers.get("content-length")
|
|
151
|
+
if content_length and int(content_length) > self.max_response_size:
|
|
152
|
+
return ToolResult(
|
|
153
|
+
success=False,
|
|
154
|
+
error=f"Response too large: {content_length} bytes"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Read response
|
|
158
|
+
content = response.text
|
|
159
|
+
|
|
160
|
+
# Try to parse JSON
|
|
161
|
+
try:
|
|
162
|
+
json_content = response.json()
|
|
163
|
+
except Exception:
|
|
164
|
+
json_content = None
|
|
165
|
+
|
|
166
|
+
return ToolResult(
|
|
167
|
+
success=200 <= response.status_code < 300,
|
|
168
|
+
output={
|
|
169
|
+
"status_code": response.status_code,
|
|
170
|
+
"headers": dict(response.headers),
|
|
171
|
+
"body": json_content if json_content else content,
|
|
172
|
+
"is_json": json_content is not None,
|
|
173
|
+
},
|
|
174
|
+
error=f"HTTP {response.status_code}" if response.status_code >= 400 else None,
|
|
175
|
+
metadata={
|
|
176
|
+
"url": str(response.url),
|
|
177
|
+
"method": method,
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
except httpx.TimeoutException:
|
|
182
|
+
return ToolResult(
|
|
183
|
+
success=False,
|
|
184
|
+
error=f"Request timed out after {self.timeout}s"
|
|
185
|
+
)
|
|
186
|
+
except httpx.RequestError as e:
|
|
187
|
+
return ToolResult(
|
|
188
|
+
success=False,
|
|
189
|
+
error=f"Request failed: {str(e)}"
|
|
190
|
+
)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return ToolResult(
|
|
193
|
+
success=False,
|
|
194
|
+
error=f"HTTP request error: {str(e)}"
|
|
195
|
+
)
|