lollmsbot 0.0.1__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.
@@ -0,0 +1,663 @@
1
+ """
2
+ Filesystem tool for LollmsBot.
3
+
4
+ This module provides the FilesystemTool class for safe file and directory
5
+ operations within allowed directories. All paths are validated to prevent
6
+ directory traversal attacks.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import zipfile
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, List, Set, Dict
15
+
16
+ from lollmsbot.agent import Tool, ToolResult
17
+
18
+
19
+ @dataclass
20
+ class PathValidationResult:
21
+ """Result of path validation."""
22
+ is_valid: bool
23
+ resolved_path: Path | None
24
+ error_message: str | None
25
+
26
+
27
+ class FilesystemTool(Tool):
28
+ """Tool for safe filesystem operations within allowed directories.
29
+
30
+ This tool provides read, write, list, and existence check operations
31
+ while enforcing strict path validation to prevent directory traversal
32
+ and unauthorized access outside allowed directories.
33
+
34
+ Attributes:
35
+ name: Unique identifier for the tool.
36
+ description: Human-readable description of what the tool does.
37
+ parameters: JSON Schema describing expected parameters for each method.
38
+ allowed_directories: Set of allowed base directories for operations.
39
+ """
40
+
41
+ name: str = "filesystem"
42
+ description: str = (
43
+ "Perform safe filesystem operations including reading files, "
44
+ "writing files, listing directories, and checking file existence. "
45
+ "All operations are restricted to allowed directories. "
46
+ "Can create HTML, CSS, JavaScript, and other files. "
47
+ "Supports creating zip archives of multiple files."
48
+ )
49
+
50
+ parameters: dict[str, Any] = {
51
+ "type": "object",
52
+ "properties": {
53
+ "operation": {
54
+ "type": "string",
55
+ "enum": ["read_file", "write_file", "list_dir", "exists", "create_html_app", "create_zip"],
56
+ "description": "The filesystem operation to perform",
57
+ },
58
+ "path": {
59
+ "type": "string",
60
+ "description": "Relative path to the file or directory",
61
+ },
62
+ "content": {
63
+ "type": "string",
64
+ "description": "Content to write (required for write_file operation)",
65
+ },
66
+ "filename": {
67
+ "type": "string",
68
+ "description": "Filename for the HTML app (required for create_html_app)",
69
+ },
70
+ "html_content": {
71
+ "type": "string",
72
+ "description": "HTML content for the app (required for create_html_app)",
73
+ },
74
+ "files": {
75
+ "type": "array",
76
+ "description": "List of file dicts with 'path' and 'content' for batch operations (create_zip)",
77
+ "items": {
78
+ "type": "object",
79
+ "properties": {
80
+ "path": {"type": "string"},
81
+ "content": {"type": "string"},
82
+ }
83
+ }
84
+ },
85
+ "zip_name": {
86
+ "type": "string",
87
+ "description": "Name for the zip file (required for create_zip)",
88
+ },
89
+ },
90
+ "required": ["operation"],
91
+ }
92
+
93
+ def __init__(
94
+ self,
95
+ allowed_directories: List[str] | None = None,
96
+ default_encoding: str = "utf-8",
97
+ ) -> None:
98
+ """Initialize the FilesystemTool.
99
+
100
+ Args:
101
+ allowed_directories: List of allowed base directories. If None,
102
+ defaults to current working directory.
103
+ default_encoding: Default encoding for file operations.
104
+ """
105
+ self.allowed_directories: Set[Path] = set()
106
+ self.default_encoding: str = default_encoding
107
+
108
+ if allowed_directories:
109
+ for dir_path in allowed_directories:
110
+ resolved = Path(dir_path).resolve()
111
+ self.allowed_directories.add(resolved)
112
+ else:
113
+ # Default to current working directory
114
+ self.allowed_directories.add(Path.cwd().resolve())
115
+
116
+ # Add a shared output directory for generated files
117
+ self.output_dir = Path.home() / ".lollmsbot" / "output"
118
+ self.output_dir.mkdir(parents=True, exist_ok=True)
119
+ self.allowed_directories.add(self.output_dir.resolve())
120
+
121
+ # Create subdirectory for games/apps
122
+ self.apps_dir = self.output_dir / "apps"
123
+ self.apps_dir.mkdir(parents=True, exist_ok=True)
124
+ self.allowed_directories.add(self.apps_dir.resolve())
125
+
126
+ def _validate_path(self, path: str) -> PathValidationResult:
127
+ """Validate that a path is within allowed directories.
128
+
129
+ Args:
130
+ path: The path to validate (can be relative or absolute).
131
+
132
+ Returns:
133
+ PathValidationResult indicating if the path is valid and safe.
134
+ """
135
+ try:
136
+ # Resolve to absolute path, handling .. and symlinks
137
+ target_path = Path(path).resolve()
138
+
139
+ # Check if path is within any allowed directory
140
+ for allowed_dir in self.allowed_directories:
141
+ try:
142
+ # Check if target_path is within allowed_dir or equals it
143
+ target_path.relative_to(allowed_dir)
144
+ return PathValidationResult(
145
+ is_valid=True,
146
+ resolved_path=target_path,
147
+ error_message=None,
148
+ )
149
+ except ValueError:
150
+ # target_path is not under allowed_dir, try next
151
+ continue
152
+
153
+ # Path is outside all allowed directories
154
+ allowed_strs = [str(d) for d in self.allowed_directories]
155
+ return PathValidationResult(
156
+ is_valid=False,
157
+ resolved_path=None,
158
+ error_message=(
159
+ f"Path '{path}' is outside allowed directories: "
160
+ f"{', '.join(allowed_strs)}"
161
+ ),
162
+ )
163
+
164
+ except (OSError, ValueError) as exc:
165
+ return PathValidationResult(
166
+ is_valid=False,
167
+ resolved_path=None,
168
+ error_message=f"Invalid path '{path}': {str(exc)}",
169
+ )
170
+
171
+ async def read_file(self, path: str) -> ToolResult:
172
+ """Read contents of a file.
173
+
174
+ Args:
175
+ path: Path to the file to read.
176
+
177
+ Returns:
178
+ ToolResult with file content on success, error on failure.
179
+ """
180
+ validation = self._validate_path(path)
181
+ if not validation.is_valid:
182
+ return ToolResult(
183
+ success=False,
184
+ output=None,
185
+ error=validation.error_message,
186
+ )
187
+
188
+ resolved_path = validation.resolved_path
189
+
190
+ try:
191
+ if not resolved_path.exists():
192
+ return ToolResult(
193
+ success=False,
194
+ output=None,
195
+ error=f"File not found: {path}",
196
+ )
197
+
198
+ if not resolved_path.is_file():
199
+ return ToolResult(
200
+ success=False,
201
+ output=None,
202
+ error=f"Path is not a file: {path}",
203
+ )
204
+
205
+ # Read file asynchronously
206
+ loop = asyncio.get_event_loop()
207
+ content = await loop.run_in_executor(
208
+ None,
209
+ lambda: resolved_path.read_text(encoding=self.default_encoding),
210
+ )
211
+
212
+ return ToolResult(
213
+ success=True,
214
+ output=content,
215
+ error=None,
216
+ )
217
+
218
+ except PermissionError as exc:
219
+ return ToolResult(
220
+ success=False,
221
+ output=None,
222
+ error=f"Permission denied reading file '{path}': {str(exc)}",
223
+ )
224
+ except UnicodeDecodeError as exc:
225
+ return ToolResult(
226
+ success=False,
227
+ output=None,
228
+ error=f"File '{path}' is not a valid text file: {str(exc)}",
229
+ )
230
+ except Exception as exc:
231
+ return ToolResult(
232
+ success=False,
233
+ output=None,
234
+ error=f"Error reading file '{path}': {str(exc)}",
235
+ )
236
+
237
+ async def write_file(self, path: str, content: str) -> ToolResult:
238
+ """Write content to a file.
239
+
240
+ Args:
241
+ path: Path to the file to write.
242
+ content: Content to write to the file.
243
+
244
+ Returns:
245
+ ToolResult indicating success or failure, with file info for delivery.
246
+ """
247
+ validation = self._validate_path(path)
248
+ if not validation.is_valid:
249
+ return ToolResult(
250
+ success=False,
251
+ output=None,
252
+ error=validation.error_message,
253
+ )
254
+
255
+ resolved_path = validation.resolved_path
256
+
257
+ try:
258
+ # Ensure parent directory exists
259
+ parent_dir = resolved_path.parent
260
+ if not parent_dir.exists():
261
+ loop = asyncio.get_event_loop()
262
+ await loop.run_in_executor(
263
+ None,
264
+ lambda: parent_dir.mkdir(parents=True, exist_ok=True),
265
+ )
266
+
267
+ # Write file asynchronously
268
+ loop = asyncio.get_event_loop()
269
+ await loop.run_in_executor(
270
+ None,
271
+ lambda: resolved_path.write_text(
272
+ content,
273
+ encoding=self.default_encoding,
274
+ ),
275
+ )
276
+
277
+ # Return file info for potential delivery
278
+ return ToolResult(
279
+ success=True,
280
+ output=f"Successfully wrote {len(content)} characters to {path}",
281
+ error=None,
282
+ files_to_send=[{
283
+ "path": str(resolved_path),
284
+ "filename": resolved_path.name,
285
+ "description": f"Generated file: {resolved_path.name} ({len(content)} chars)",
286
+ }],
287
+ )
288
+
289
+ except PermissionError as exc:
290
+ return ToolResult(
291
+ success=False,
292
+ output=None,
293
+ error=f"Permission denied writing file '{path}': {str(exc)}",
294
+ )
295
+ except Exception as exc:
296
+ return ToolResult(
297
+ success=False,
298
+ output=None,
299
+ error=f"Error writing file '{path}': {str(exc)}",
300
+ )
301
+
302
+ async def create_html_app(self, filename: str, html_content: str) -> ToolResult:
303
+ """Create a complete HTML application file.
304
+
305
+ Convenience method for creating HTML files with proper structure.
306
+
307
+ Args:
308
+ filename: Name for the HTML file (should end in .html).
309
+ html_content: The HTML content to write.
310
+
311
+ Returns:
312
+ ToolResult with file info for delivery.
313
+ """
314
+ # Ensure .html extension
315
+ if not filename.endswith(".html"):
316
+ filename += ".html"
317
+
318
+ # Use apps directory for generated apps
319
+ output_path = self.apps_dir / filename
320
+
321
+ # Wrap in proper HTML structure if not already present
322
+ if not html_content.strip().startswith("<!DOCTYPE"):
323
+ full_html = f"""<!DOCTYPE html>
324
+ <html lang="en">
325
+ <head>
326
+ <meta charset="UTF-8">
327
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
328
+ <title>{filename.replace('.html', '').replace('_', ' ').title()}</title>
329
+ <style>
330
+ body {{
331
+ font-family: system-ui, -apple-system, sans-serif;
332
+ max-width: 1200px;
333
+ margin: 0 auto;
334
+ padding: 20px;
335
+ background: #1a1a2e;
336
+ color: #eee;
337
+ }}
338
+ canvas {{
339
+ display: block;
340
+ margin: 20px auto;
341
+ background: #16213e;
342
+ border-radius: 8px;
343
+ }}
344
+ .game-container {{
345
+ text-align: center;
346
+ }}
347
+ button {{
348
+ background: #0f3460;
349
+ color: white;
350
+ border: none;
351
+ padding: 10px 20px;
352
+ border-radius: 4px;
353
+ cursor: pointer;
354
+ margin: 5px;
355
+ }}
356
+ button:hover {{
357
+ background: #e94560;
358
+ }}
359
+ </style>
360
+ </head>
361
+ <body>
362
+ <div class="game-container">
363
+ {html_content}
364
+ </div>
365
+ </body>
366
+ </html>"""
367
+ else:
368
+ full_html = html_content
369
+
370
+ return await self.write_file(str(output_path), full_html)
371
+
372
+ async def create_zip(self, zip_name: str, files: List[Dict[str, str]]) -> ToolResult:
373
+ """Create a zip archive containing multiple files.
374
+
375
+ Args:
376
+ zip_name: Name for the zip file (should end in .zip).
377
+ files: List of dicts with 'path' and 'content' keys.
378
+
379
+ Returns:
380
+ ToolResult with zip file info for delivery.
381
+ """
382
+ # Ensure .zip extension
383
+ if not zip_name.endswith(".zip"):
384
+ zip_name += ".zip"
385
+
386
+ zip_path = self.output_dir / zip_name
387
+
388
+ try:
389
+ loop = asyncio.get_event_loop()
390
+
391
+ def create_zip():
392
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
393
+ for file_info in files:
394
+ file_path = file_info.get("path", "")
395
+ content = file_info.get("content", "")
396
+
397
+ # Write to zip with just the filename (no full path)
398
+ arcname = Path(file_path).name
399
+ zf.writestr(arcname, content)
400
+
401
+ return zip_path
402
+
403
+ await loop.run_in_executor(None, create_zip)
404
+
405
+ # Add to allowed directories for reading
406
+ self.allowed_directories.add(self.output_dir.resolve())
407
+
408
+ return ToolResult(
409
+ success=True,
410
+ output=f"Created zip archive with {len(files)} files: {zip_name}",
411
+ error=None,
412
+ files_to_send=[{
413
+ "path": str(zip_path),
414
+ "filename": zip_name,
415
+ "description": f"Archive containing {len(files)} files",
416
+ }],
417
+ )
418
+
419
+ except Exception as exc:
420
+ return ToolResult(
421
+ success=False,
422
+ output=None,
423
+ error=f"Error creating zip: {str(exc)}",
424
+ )
425
+
426
+ async def list_dir(self, path: str) -> ToolResult:
427
+ """List contents of a directory.
428
+
429
+ Args:
430
+ path: Path to the directory to list.
431
+
432
+ Returns:
433
+ ToolResult with list of entries on success.
434
+ """
435
+ validation = self._validate_path(path)
436
+ if not validation.is_valid:
437
+ return ToolResult(
438
+ success=False,
439
+ output=None,
440
+ error=validation.error_message,
441
+ )
442
+
443
+ resolved_path = validation.resolved_path
444
+
445
+ try:
446
+ if not resolved_path.exists():
447
+ return ToolResult(
448
+ success=False,
449
+ output=None,
450
+ error=f"Directory not found: {path}",
451
+ )
452
+
453
+ if not resolved_path.is_dir():
454
+ return ToolResult(
455
+ success=False,
456
+ output=None,
457
+ error=f"Path is not a directory: {path}",
458
+ )
459
+
460
+ # List directory asynchronously
461
+ loop = asyncio.get_event_loop()
462
+ entries = await loop.run_in_executor(None, lambda: list(resolved_path.iterdir()))
463
+
464
+ # Format entries with metadata
465
+ result_entries: List[dict[str, Any]] = []
466
+ for entry in entries:
467
+ try:
468
+ stat = entry.stat()
469
+ entry_info = {
470
+ "name": entry.name,
471
+ "path": str(entry.relative_to(resolved_path)),
472
+ "type": "directory" if entry.is_dir() else "file",
473
+ "size": stat.st_size if entry.is_file() else None,
474
+ }
475
+ result_entries.append(entry_info)
476
+ except (OSError, PermissionError):
477
+ # Skip entries we can't stat
478
+ result_entries.append({
479
+ "name": entry.name,
480
+ "path": str(entry.relative_to(resolved_path)),
481
+ "type": "unknown",
482
+ "size": None,
483
+ })
484
+
485
+ return ToolResult(
486
+ success=True,
487
+ output={
488
+ "path": str(resolved_path),
489
+ "entries": result_entries,
490
+ "count": len(result_entries),
491
+ },
492
+ error=None,
493
+ )
494
+
495
+ except PermissionError as exc:
496
+ return ToolResult(
497
+ success=False,
498
+ output=None,
499
+ error=f"Permission denied listing directory '{path}': {str(exc)}",
500
+ )
501
+ except Exception as exc:
502
+ return ToolResult(
503
+ success=False,
504
+ output=None,
505
+ error=f"Error listing directory '{path}': {str(exc)}",
506
+ )
507
+
508
+ async def exists(self, path: str) -> ToolResult:
509
+ """Check if a file or directory exists.
510
+
511
+ Args:
512
+ path: Path to check.
513
+
514
+ Returns:
515
+ ToolResult with existence status and type information.
516
+ """
517
+ validation = self._validate_path(path)
518
+ if not validation.is_valid:
519
+ return ToolResult(
520
+ success=False,
521
+ output=None,
522
+ error=validation.error_message,
523
+ )
524
+
525
+ resolved_path = validation.resolved_path
526
+
527
+ try:
528
+ loop = asyncio.get_event_loop()
529
+ exists_result = await loop.run_in_executor(None, resolved_path.exists)
530
+
531
+ if not exists_result:
532
+ return ToolResult(
533
+ success=True,
534
+ output={
535
+ "exists": False,
536
+ "type": None,
537
+ "path": str(resolved_path),
538
+ },
539
+ error=None,
540
+ )
541
+
542
+ # Determine type
543
+ is_file = await loop.run_in_executor(None, resolved_path.is_file)
544
+ is_dir = await loop.run_in_executor(None, resolved_path.is_dir)
545
+
546
+ path_type = "file" if is_file else "directory" if is_dir else "other"
547
+
548
+ return ToolResult(
549
+ success=True,
550
+ output={
551
+ "exists": True,
552
+ "type": path_type,
553
+ "path": str(resolved_path),
554
+ },
555
+ error=None,
556
+ )
557
+
558
+ except Exception as exc:
559
+ return ToolResult(
560
+ success=False,
561
+ output=None,
562
+ error=f"Error checking existence of '{path}': {str(exc)}",
563
+ )
564
+
565
+ async def execute(self, **params: Any) -> ToolResult:
566
+ """Execute a filesystem operation based on parameters.
567
+
568
+ This is the main entry point for the Tool base class. It dispatches
569
+ to the appropriate method based on the 'operation' parameter.
570
+
571
+ Args:
572
+ **params: Parameters must include:
573
+ - operation: One of 'read_file', 'write_file', 'list_dir', 'exists', 'create_html_app', 'create_zip'
574
+ - path: Path for the operation (not needed for create_html_app, create_zip)
575
+ - content: Required for 'write_file' operation
576
+ - filename: Required for 'create_html_app'
577
+ - html_content: Required for 'create_html_app'
578
+ - files: Required for 'create_zip'
579
+ - zip_name: Required for 'create_zip'
580
+
581
+ Returns:
582
+ ToolResult from the executed operation.
583
+ """
584
+ operation = params.get("operation")
585
+ path = params.get("path")
586
+
587
+ if not operation:
588
+ return ToolResult(
589
+ success=False,
590
+ output=None,
591
+ error="Missing required parameter: 'operation'",
592
+ )
593
+
594
+ # Special cases that don't need path
595
+ if operation == "create_html_app":
596
+ filename = params.get("filename")
597
+ html_content = params.get("html_content")
598
+ if not filename:
599
+ return ToolResult(
600
+ success=False,
601
+ output=None,
602
+ error="Missing required parameter: 'filename' for create_html_app",
603
+ )
604
+ if html_content is None:
605
+ return ToolResult(
606
+ success=False,
607
+ output=None,
608
+ error="Missing required parameter: 'html_content' for create_html_app",
609
+ )
610
+ return await self.create_html_app(filename, html_content)
611
+
612
+ if operation == "create_zip":
613
+ zip_name = params.get("zip_name")
614
+ files = params.get("files", [])
615
+ if not zip_name:
616
+ return ToolResult(
617
+ success=False,
618
+ output=None,
619
+ error="Missing required parameter: 'zip_name' for create_zip",
620
+ )
621
+ if not files:
622
+ return ToolResult(
623
+ success=False,
624
+ output=None,
625
+ error="Missing required parameter: 'files' for create_zip",
626
+ )
627
+ return await self.create_zip(zip_name, files)
628
+
629
+ # All other operations need path
630
+ if not path:
631
+ return ToolResult(
632
+ success=False,
633
+ output=None,
634
+ error="Missing required parameter: 'path'",
635
+ )
636
+
637
+ # Dispatch to appropriate method
638
+ if operation == "read_file":
639
+ return await self.read_file(path)
640
+
641
+ elif operation == "write_file":
642
+ content = params.get("content")
643
+ if content is None:
644
+ return ToolResult(
645
+ success=False,
646
+ output=None,
647
+ error="Missing required parameter 'content' for write_file operation",
648
+ )
649
+ return await self.write_file(path, content)
650
+
651
+ elif operation == "list_dir":
652
+ return await self.list_dir(path)
653
+
654
+ elif operation == "exists":
655
+ return await self.exists(path)
656
+
657
+ else:
658
+ return ToolResult(
659
+ success=False,
660
+ output=None,
661
+ error=f"Unknown operation: '{operation}'. "
662
+ f"Valid operations are: read_file, write_file, list_dir, exists, create_html_app, create_zip",
663
+ )