qodalis-cli-server 1.0.0b1__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 (42) hide show
  1. qodalis_cli/__init__.py +112 -0
  2. qodalis_cli/abstractions/__init__.py +25 -0
  3. qodalis_cli/abstractions/cli_command_author.py +7 -0
  4. qodalis_cli/abstractions/cli_command_parameter_descriptor.py +6 -0
  5. qodalis_cli/abstractions/cli_command_processor.py +6 -0
  6. qodalis_cli/abstractions/cli_process_command.py +3 -0
  7. qodalis_cli/controllers/__init__.py +11 -0
  8. qodalis_cli/controllers/cli_controller.py +111 -0
  9. qodalis_cli/controllers/cli_controller_v2.py +91 -0
  10. qodalis_cli/controllers/cli_version_controller.py +45 -0
  11. qodalis_cli/controllers/filesystem_controller.py +329 -0
  12. qodalis_cli/create_cli_server.py +157 -0
  13. qodalis_cli/extensions/__init__.py +3 -0
  14. qodalis_cli/extensions/cli_builder.py +51 -0
  15. qodalis_cli/filesystem/__init__.py +4 -0
  16. qodalis_cli/filesystem/filesystem_options.py +16 -0
  17. qodalis_cli/filesystem/filesystem_path_validator.py +42 -0
  18. qodalis_cli/models/__init__.py +13 -0
  19. qodalis_cli/models/cli_server_command_descriptor.py +30 -0
  20. qodalis_cli/models/cli_server_output.py +47 -0
  21. qodalis_cli/models/cli_server_response.py +12 -0
  22. qodalis_cli/processors/__init__.py +17 -0
  23. qodalis_cli/processors/cli_base64_command_processor.py +61 -0
  24. qodalis_cli/processors/cli_echo_command_processor.py +17 -0
  25. qodalis_cli/processors/cli_hash_command_processor.py +49 -0
  26. qodalis_cli/processors/cli_http_command_processor.py +143 -0
  27. qodalis_cli/processors/cli_status_command_processor.py +24 -0
  28. qodalis_cli/processors/cli_system_command_processor.py +34 -0
  29. qodalis_cli/processors/cli_uuid_command_processor.py +39 -0
  30. qodalis_cli/server.py +44 -0
  31. qodalis_cli/services/__init__.py +19 -0
  32. qodalis_cli/services/cli_command_executor_service.py +62 -0
  33. qodalis_cli/services/cli_command_registry.py +68 -0
  34. qodalis_cli/services/cli_event_socket_manager.py +46 -0
  35. qodalis_cli/services/cli_log_socket_manager.py +153 -0
  36. qodalis_cli/services/cli_response_builder.py +73 -0
  37. qodalis_cli/services/cli_shell_session_manager.py +201 -0
  38. qodalis_cli_server-1.0.0b1.dist-info/METADATA +689 -0
  39. qodalis_cli_server-1.0.0b1.dist-info/RECORD +42 -0
  40. qodalis_cli_server-1.0.0b1.dist-info/WHEEL +4 -0
  41. qodalis_cli_server-1.0.0b1.dist-info/entry_points.txt +2 -0
  42. qodalis_cli_server-1.0.0b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,329 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import stat
6
+ from dataclasses import asdict
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Union
9
+
10
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
11
+ from fastapi.responses import FileResponse, StreamingResponse
12
+
13
+ from qodalis_cli_filesystem import (
14
+ FileStorageExistsError,
15
+ FileStorageIsADirectoryError,
16
+ FileStorageNotADirectoryError,
17
+ FileStorageNotFoundError,
18
+ FileStoragePermissionError,
19
+ IFileStorageProvider,
20
+ )
21
+
22
+ from ..filesystem.filesystem_path_validator import FileSystemPathValidator
23
+
24
+
25
+ def _map_provider_error(exc: Exception) -> HTTPException:
26
+ """Map a file-storage error to the appropriate HTTP exception."""
27
+ if isinstance(exc, FileStorageNotFoundError):
28
+ return HTTPException(status_code=404, detail=str(exc))
29
+ if isinstance(exc, FileStoragePermissionError):
30
+ return HTTPException(status_code=403, detail=str(exc))
31
+ if isinstance(exc, (FileStorageNotADirectoryError, FileStorageIsADirectoryError)):
32
+ return HTTPException(status_code=400, detail=str(exc))
33
+ if isinstance(exc, FileStorageExistsError):
34
+ return HTTPException(status_code=409, detail=str(exc))
35
+ return HTTPException(status_code=500, detail=str(exc))
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Provider-based router (new)
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def create_filesystem_router(
44
+ provider_or_validator: Union[IFileStorageProvider, FileSystemPathValidator],
45
+ ) -> APIRouter:
46
+ """Create a FastAPI router exposing filesystem operations.
47
+
48
+ Accepts either a :class:`IFileStorageProvider` (new approach) or a
49
+ :class:`FileSystemPathValidator` (legacy approach) for backward
50
+ compatibility.
51
+ """
52
+ if isinstance(provider_or_validator, FileSystemPathValidator):
53
+ return _create_legacy_router(provider_or_validator)
54
+
55
+ return _create_provider_router(provider_or_validator)
56
+
57
+
58
+ def _create_provider_router(provider: IFileStorageProvider) -> APIRouter:
59
+ """Create a router that delegates to *provider*."""
60
+
61
+ router = APIRouter()
62
+
63
+ @router.get("/ls")
64
+ async def list_directory(path: str) -> dict[str, Any]:
65
+ try:
66
+ entries = await provider.list(path)
67
+ return {"entries": [asdict(e) for e in entries]}
68
+ except (
69
+ FileStorageNotFoundError,
70
+ FileStorageNotADirectoryError,
71
+ FileStoragePermissionError,
72
+ ) as exc:
73
+ raise _map_provider_error(exc) from exc
74
+
75
+ @router.get("/cat")
76
+ async def read_file(path: str) -> dict[str, str]:
77
+ try:
78
+ content = await provider.read_file(path)
79
+ return {"content": content}
80
+ except (
81
+ FileStorageNotFoundError,
82
+ FileStorageIsADirectoryError,
83
+ FileStoragePermissionError,
84
+ ) as exc:
85
+ raise _map_provider_error(exc) from exc
86
+
87
+ @router.get("/stat")
88
+ async def stat_path(path: str) -> dict[str, Any]:
89
+ try:
90
+ info = await provider.stat(path)
91
+ return asdict(info)
92
+ except (
93
+ FileStorageNotFoundError,
94
+ FileStoragePermissionError,
95
+ ) as exc:
96
+ raise _map_provider_error(exc) from exc
97
+
98
+ @router.get("/download")
99
+ async def download_file(path: str) -> StreamingResponse:
100
+ try:
101
+ stream = await provider.get_download_stream(path)
102
+ filename = path.rstrip("/").rsplit("/", 1)[-1] or "download"
103
+ return StreamingResponse(
104
+ stream,
105
+ media_type="application/octet-stream",
106
+ headers={
107
+ "Content-Disposition": f'attachment; filename="{filename}"'
108
+ },
109
+ )
110
+ except (
111
+ FileStorageNotFoundError,
112
+ FileStorageIsADirectoryError,
113
+ FileStoragePermissionError,
114
+ ) as exc:
115
+ raise _map_provider_error(exc) from exc
116
+
117
+ @router.post("/upload")
118
+ async def upload_file(
119
+ file: UploadFile = File(...),
120
+ path: str = Form(...),
121
+ ) -> dict[str, str]:
122
+ try:
123
+ contents = await file.read()
124
+ await provider.upload_file(path, contents)
125
+ return {"path": path, "status": "uploaded"}
126
+ except (
127
+ FileStorageNotFoundError,
128
+ FileStorageIsADirectoryError,
129
+ FileStoragePermissionError,
130
+ ) as exc:
131
+ raise _map_provider_error(exc) from exc
132
+
133
+ @router.post("/mkdir")
134
+ async def make_directory(body: dict[str, Any]) -> dict[str, str]:
135
+ raw_path = body.get("path")
136
+ if not raw_path:
137
+ raise HTTPException(status_code=400, detail="'path' is required")
138
+
139
+ recursive = body.get("recursive", True)
140
+ try:
141
+ await provider.mkdir(raw_path, recursive=recursive)
142
+ return {"path": raw_path, "status": "created"}
143
+ except (
144
+ FileStorageNotFoundError,
145
+ FileStorageExistsError,
146
+ FileStoragePermissionError,
147
+ ) as exc:
148
+ raise _map_provider_error(exc) from exc
149
+
150
+ @router.delete("/rm")
151
+ async def remove_path(path: str, recursive: bool = True) -> dict[str, str]:
152
+ try:
153
+ await provider.remove(path, recursive=recursive)
154
+ return {"path": path, "status": "deleted"}
155
+ except (
156
+ FileStorageNotFoundError,
157
+ FileStoragePermissionError,
158
+ ) as exc:
159
+ raise _map_provider_error(exc) from exc
160
+
161
+ return router
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Legacy validator-based router (backward compatible)
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _safe_validate(validator: FileSystemPathValidator, path: str) -> str:
170
+ """Validate *path* via the validator, raising a 403 on failure."""
171
+ try:
172
+ return validator.validate(path)
173
+ except PermissionError as exc:
174
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
175
+
176
+
177
+ def _stat_entry(resolved: str) -> dict[str, Any]:
178
+ """Return a stat dictionary for the given resolved path."""
179
+ st = os.stat(resolved)
180
+ entry_type = "directory" if stat.S_ISDIR(st.st_mode) else "file"
181
+ return {
182
+ "name": os.path.basename(resolved),
183
+ "type": entry_type,
184
+ "size": st.st_size,
185
+ "modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
186
+ "created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).isoformat(),
187
+ "permissions": stat.filemode(st.st_mode),
188
+ }
189
+
190
+
191
+ def _create_legacy_router(validator: FileSystemPathValidator) -> APIRouter:
192
+ """Create a router using the legacy :class:`FileSystemPathValidator`."""
193
+
194
+ router = APIRouter()
195
+
196
+ @router.get("/ls")
197
+ async def list_directory(path: str) -> dict[str, Any]:
198
+ resolved = _safe_validate(validator, path)
199
+
200
+ if not os.path.exists(resolved):
201
+ raise HTTPException(status_code=404, detail="Path not found")
202
+ if not os.path.isdir(resolved):
203
+ raise HTTPException(status_code=400, detail="Path is not a directory")
204
+
205
+ entries: list[dict[str, Any]] = []
206
+ try:
207
+ for name in sorted(os.listdir(resolved)):
208
+ full = os.path.join(resolved, name)
209
+ try:
210
+ st = os.stat(full)
211
+ entry_type = "directory" if stat.S_ISDIR(st.st_mode) else "file"
212
+ entries.append(
213
+ {
214
+ "name": name,
215
+ "type": entry_type,
216
+ "size": st.st_size,
217
+ "modified": datetime.fromtimestamp(
218
+ st.st_mtime, tz=timezone.utc
219
+ ).isoformat(),
220
+ "permissions": stat.filemode(st.st_mode),
221
+ }
222
+ )
223
+ except OSError:
224
+ continue
225
+ except OSError as exc:
226
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
227
+
228
+ return {"entries": entries}
229
+
230
+ @router.get("/cat")
231
+ async def read_file(path: str) -> dict[str, str]:
232
+ resolved = _safe_validate(validator, path)
233
+
234
+ if not os.path.exists(resolved):
235
+ raise HTTPException(status_code=404, detail="Path not found")
236
+ if not os.path.isfile(resolved):
237
+ raise HTTPException(status_code=400, detail="Path is not a file")
238
+
239
+ try:
240
+ with open(resolved, "r", encoding="utf-8", errors="replace") as fh:
241
+ content = fh.read()
242
+ except OSError as exc:
243
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
244
+
245
+ return {"content": content}
246
+
247
+ @router.get("/stat")
248
+ async def stat_path(path: str) -> dict[str, Any]:
249
+ resolved = _safe_validate(validator, path)
250
+
251
+ if not os.path.exists(resolved):
252
+ raise HTTPException(status_code=404, detail="Path not found")
253
+
254
+ try:
255
+ return _stat_entry(resolved)
256
+ except OSError as exc:
257
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
258
+
259
+ @router.get("/download")
260
+ async def download_file(path: str) -> FileResponse:
261
+ resolved = _safe_validate(validator, path)
262
+
263
+ if not os.path.exists(resolved):
264
+ raise HTTPException(status_code=404, detail="Path not found")
265
+ if not os.path.isfile(resolved):
266
+ raise HTTPException(status_code=400, detail="Path is not a file")
267
+
268
+ filename = os.path.basename(resolved)
269
+ return FileResponse(
270
+ resolved,
271
+ filename=filename,
272
+ media_type="application/octet-stream",
273
+ )
274
+
275
+ @router.post("/upload")
276
+ async def upload_file(
277
+ file: UploadFile = File(...),
278
+ path: str = Form(...),
279
+ ) -> dict[str, str]:
280
+ resolved = _safe_validate(validator, path)
281
+
282
+ parent = os.path.dirname(resolved)
283
+ if not os.path.isdir(parent):
284
+ raise HTTPException(
285
+ status_code=400, detail="Parent directory does not exist"
286
+ )
287
+
288
+ try:
289
+ contents = await file.read()
290
+ with open(resolved, "wb") as fh:
291
+ fh.write(contents)
292
+ except OSError as exc:
293
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
294
+
295
+ return {"path": resolved, "status": "uploaded"}
296
+
297
+ @router.post("/mkdir")
298
+ async def make_directory(body: dict[str, str]) -> dict[str, str]:
299
+ raw_path = body.get("path")
300
+ if not raw_path:
301
+ raise HTTPException(status_code=400, detail="'path' is required")
302
+
303
+ resolved = _safe_validate(validator, raw_path)
304
+
305
+ try:
306
+ os.makedirs(resolved, exist_ok=True)
307
+ except OSError as exc:
308
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
309
+
310
+ return {"path": resolved, "status": "created"}
311
+
312
+ @router.delete("/rm")
313
+ async def remove_path(path: str) -> dict[str, str]:
314
+ resolved = _safe_validate(validator, path)
315
+
316
+ if not os.path.exists(resolved):
317
+ raise HTTPException(status_code=404, detail="Path not found")
318
+
319
+ try:
320
+ if os.path.isdir(resolved):
321
+ shutil.rmtree(resolved)
322
+ else:
323
+ os.remove(resolved)
324
+ except OSError as exc:
325
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
326
+
327
+ return {"path": resolved, "status": "deleted"}
328
+
329
+ return router
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import dataclass, field
7
+ from typing import Callable
8
+
9
+ from fastapi import FastAPI, WebSocket
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+
12
+ from qodalis_cli_filesystem.providers.os_provider import OsFileStorageProvider, OsProviderOptions
13
+
14
+ from .controllers import create_cli_router, create_cli_router_v2, create_cli_version_router
15
+ from .controllers.filesystem_controller import create_filesystem_router
16
+ from .extensions import CliBuilder
17
+ from .filesystem import FileSystemPathValidator
18
+ from .services import (
19
+ CliCommandExecutorService,
20
+ CliCommandRegistry,
21
+ CliEventSocketManager,
22
+ CliLogSocketManager,
23
+ CliShellSessionManager,
24
+ WebSocketLogHandler,
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class CliServerOptions:
30
+ base_path: str = "/api/cli"
31
+ cors: bool = True
32
+ cors_origins: list[str] = field(default_factory=lambda: ["*"])
33
+ configure: Callable[[CliBuilder], None] | None = None
34
+
35
+
36
+ @dataclass
37
+ class CliServerResult:
38
+ app: FastAPI
39
+ registry: CliCommandRegistry
40
+ builder: CliBuilder
41
+ event_socket_manager: CliEventSocketManager
42
+ log_socket_manager: CliLogSocketManager
43
+
44
+
45
+ def create_cli_server(options: CliServerOptions | None = None) -> CliServerResult:
46
+ opts = options or CliServerOptions()
47
+
48
+ event_socket_manager = CliEventSocketManager()
49
+ log_socket_manager = CliLogSocketManager()
50
+ shell_session_manager = CliShellSessionManager()
51
+
52
+ # Attach a log handler that forwards to WebSocket clients
53
+ log_handler = WebSocketLogHandler(log_socket_manager)
54
+ logging.getLogger().addHandler(log_handler)
55
+
56
+ @asynccontextmanager
57
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
58
+ yield
59
+ await event_socket_manager.broadcast_disconnect()
60
+ await log_socket_manager.broadcast_disconnect()
61
+ logging.getLogger().removeHandler(log_handler)
62
+
63
+ app = FastAPI(title="Qodalis CLI Server", lifespan=lifespan)
64
+
65
+ if opts.cors:
66
+ app.add_middleware(
67
+ CORSMiddleware,
68
+ allow_origins=opts.cors_origins,
69
+ allow_credentials=True,
70
+ allow_methods=["*"],
71
+ allow_headers=["*"],
72
+ )
73
+
74
+ registry = CliCommandRegistry()
75
+ builder = CliBuilder(registry)
76
+
77
+ if opts.configure:
78
+ opts.configure(builder)
79
+
80
+ executor = CliCommandExecutorService(registry)
81
+ router = create_cli_router(registry, executor)
82
+ router_v2 = create_cli_router_v2(registry, executor)
83
+ version_router = create_cli_version_router()
84
+
85
+ # API v1 routes
86
+ app.include_router(router, prefix="/api/v1/cli")
87
+
88
+ # API v2 routes
89
+ app.include_router(router_v2, prefix="/api/v2/cli")
90
+
91
+ # Version discovery routes
92
+ app.include_router(version_router, prefix="/api/cli")
93
+
94
+ # Custom basePath fallback (when user overrides the default)
95
+ if opts.base_path != "/api/v1/cli":
96
+ app.include_router(router, prefix=opts.base_path)
97
+
98
+ # Filesystem API (opt-in via builder.set_file_storage_provider() or
99
+ # legacy builder.add_filesystem())
100
+ if builder.file_storage_provider is not None:
101
+ fs_router = create_filesystem_router(builder.file_storage_provider)
102
+ app.include_router(fs_router, prefix="/api/cli/fs")
103
+ elif builder.filesystem_options is not None:
104
+ # Legacy path: create an OsFileStorageProvider from the options
105
+ os_provider = OsFileStorageProvider(
106
+ OsProviderOptions(
107
+ allowed_paths=builder.filesystem_options.allowed_paths
108
+ )
109
+ )
110
+ fs_router = create_filesystem_router(os_provider)
111
+ app.include_router(fs_router, prefix="/api/cli/fs")
112
+
113
+ # WebSocket event stream
114
+ @app.websocket("/ws/v1/cli/events")
115
+ async def websocket_events_v1(websocket: WebSocket) -> None:
116
+ await event_socket_manager.handle_connection(websocket)
117
+
118
+ @app.websocket("/ws/cli/events")
119
+ async def websocket_events(websocket: WebSocket) -> None:
120
+ await event_socket_manager.handle_connection(websocket)
121
+
122
+ # Log WebSocket endpoints
123
+ async def _handle_logs(websocket: WebSocket) -> None:
124
+ level_filter = websocket.query_params.get("level") or None
125
+ await log_socket_manager.handle_connection(websocket, level_filter)
126
+
127
+ @app.websocket("/ws/v1/cli/logs")
128
+ async def websocket_logs_v1(websocket: WebSocket) -> None:
129
+ await _handle_logs(websocket)
130
+
131
+ @app.websocket("/ws/cli/logs")
132
+ async def websocket_logs(websocket: WebSocket) -> None:
133
+ await _handle_logs(websocket)
134
+
135
+ # Shell WebSocket endpoints
136
+ async def _handle_shell(websocket: WebSocket) -> None:
137
+ await websocket.accept()
138
+ cols = int(websocket.query_params.get("cols", "80")) or 80
139
+ rows = int(websocket.query_params.get("rows", "24")) or 24
140
+ cmd = websocket.query_params.get("cmd") or None
141
+ await shell_session_manager.handle_session(websocket, cols, rows, cmd)
142
+
143
+ @app.websocket("/ws/v1/cli/shell")
144
+ async def websocket_shell_v1(websocket: WebSocket) -> None:
145
+ await _handle_shell(websocket)
146
+
147
+ @app.websocket("/ws/cli/shell")
148
+ async def websocket_shell(websocket: WebSocket) -> None:
149
+ await _handle_shell(websocket)
150
+
151
+ return CliServerResult(
152
+ app=app,
153
+ registry=registry,
154
+ builder=builder,
155
+ event_socket_manager=event_socket_manager,
156
+ log_socket_manager=log_socket_manager,
157
+ )
@@ -0,0 +1,3 @@
1
+ from .cli_builder import CliBuilder
2
+
3
+ __all__ = ["CliBuilder"]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ..abstractions import ICliCommandProcessor, ICliModule
6
+ from ..filesystem import FileSystemOptions
7
+ from ..services.cli_command_registry import CliCommandRegistry
8
+
9
+ if TYPE_CHECKING:
10
+ from qodalis_cli_filesystem import IFileStorageProvider
11
+
12
+
13
+ class CliBuilder:
14
+ def __init__(self, registry: CliCommandRegistry) -> None:
15
+ self._registry = registry
16
+ self._filesystem_options: FileSystemOptions | None = None
17
+ self._file_storage_provider: IFileStorageProvider | None = None
18
+
19
+ def add_processor(self, processor: ICliCommandProcessor) -> CliBuilder:
20
+ self._registry.register(processor)
21
+ return self
22
+
23
+ def add_module(self, module: ICliModule) -> CliBuilder:
24
+ for processor in module.processors:
25
+ self._registry.register(processor)
26
+ return self
27
+
28
+ def add_filesystem(self, options: FileSystemOptions) -> CliBuilder:
29
+ """Enable the filesystem API with the given options (legacy)."""
30
+ self._filesystem_options = options
31
+ return self
32
+
33
+ def set_file_storage_provider(
34
+ self, provider: IFileStorageProvider
35
+ ) -> CliBuilder:
36
+ """Set a custom file storage provider for the filesystem API."""
37
+ self._file_storage_provider = provider
38
+ return self
39
+
40
+ @property
41
+ def file_storage_provider(self) -> IFileStorageProvider | None:
42
+ """Return the configured file storage provider, if any."""
43
+ return self._file_storage_provider
44
+
45
+ @property
46
+ def filesystem_options(self) -> FileSystemOptions | None:
47
+ return self._filesystem_options
48
+
49
+ @property
50
+ def registry(self) -> CliCommandRegistry:
51
+ return self._registry
@@ -0,0 +1,4 @@
1
+ from .filesystem_options import FileSystemOptions
2
+ from .filesystem_path_validator import FileSystemPathValidator
3
+
4
+ __all__ = ["FileSystemOptions", "FileSystemPathValidator"]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class FileSystemOptions:
8
+ """Configuration for the filesystem API.
9
+
10
+ Attributes:
11
+ allowed_paths: List of absolute directory paths that clients are
12
+ permitted to access. Every requested path is resolved and
13
+ checked against this whitelist before any I/O is performed.
14
+ """
15
+
16
+ allowed_paths: list[str] = field(default_factory=list)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ class FileSystemPathValidator:
7
+ """Validates that requested paths fall within an allowed whitelist.
8
+
9
+ Every path is resolved to its real, absolute form (following symlinks)
10
+ before the check, which prevents path-traversal attacks.
11
+ """
12
+
13
+ def __init__(self, allowed_paths: list[str]) -> None:
14
+ # Pre-resolve allowed roots so comparisons are consistent.
15
+ self._allowed_paths: list[str] = [
16
+ os.path.realpath(p) for p in allowed_paths
17
+ ]
18
+
19
+ @property
20
+ def allowed_paths(self) -> list[str]:
21
+ return list(self._allowed_paths)
22
+
23
+ def validate(self, path: str) -> str:
24
+ """Resolve *path* and verify it lives under an allowed root.
25
+
26
+ Returns:
27
+ The resolved absolute path on success.
28
+
29
+ Raises:
30
+ PermissionError: If the resolved path is not within any
31
+ allowed directory.
32
+ """
33
+ resolved = os.path.realpath(path)
34
+
35
+ for allowed in self._allowed_paths:
36
+ # Use os.sep to ensure "/tmp/evil" doesn't match "/tmp/ev"
37
+ if resolved == allowed or resolved.startswith(allowed + os.sep):
38
+ return resolved
39
+
40
+ raise PermissionError(
41
+ f"Access denied: '{path}' is outside the allowed paths"
42
+ )
@@ -0,0 +1,13 @@
1
+ from .cli_server_output import CliServerOutput
2
+ from .cli_server_response import CliServerResponse
3
+ from .cli_server_command_descriptor import (
4
+ CliServerCommandDescriptor,
5
+ CliServerCommandParameterDescriptorDto,
6
+ )
7
+
8
+ __all__ = [
9
+ "CliServerOutput",
10
+ "CliServerResponse",
11
+ "CliServerCommandDescriptor",
12
+ "CliServerCommandParameterDescriptorDto",
13
+ ]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class CliServerCommandParameterDescriptorDto(BaseModel):
9
+ name: str
10
+ description: str
11
+ required: bool = False
12
+ type: str = "string"
13
+ aliases: list[str] | None = None
14
+ default_value: Any = Field(alias="defaultValue", default=None)
15
+
16
+ model_config = {"populate_by_name": True}
17
+
18
+
19
+ class CliServerCommandDescriptor(BaseModel):
20
+ command: str
21
+ description: str
22
+ version: str = "1.0.0"
23
+ api_version: int | None = Field(alias="apiVersion", default=None)
24
+ parameters: list[CliServerCommandParameterDescriptorDto] | None = None
25
+ processors: list["CliServerCommandDescriptor"] | None = None
26
+ allow_unlisted_commands: bool | None = Field(alias="allowUnlistedCommands", default=None)
27
+ value_required: bool | None = Field(alias="valueRequired", default=None)
28
+ author: dict[str, str] | None = None
29
+
30
+ model_config = {"populate_by_name": True}