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,519 @@
1
+ """
2
+ Shell tool for LollmsBot.
3
+
4
+ This module provides the ShellTool class for safe shell command execution
5
+ with strict security controls including allowlist/denylist filtering,
6
+ timeout protection, and comprehensive output capture.
7
+
8
+ SECURITY WARNING:
9
+ This tool executes system shell commands which can be dangerous. It includes
10
+ multiple security layers to mitigate risks, but should still be used with
11
+ caution. Always review the allowlist/denylist configuration carefully.
12
+
13
+ Security features:
14
+ - Explicit allowlist for permitted commands (opt-in security)
15
+ - Denylist for known dangerous commands and patterns
16
+ - Timeout protection to prevent hanging processes
17
+ - Working directory restriction
18
+ - No shell=True to prevent injection attacks
19
+ - Command argument validation
20
+ """
21
+
22
+ import asyncio
23
+ import re
24
+ import shlex
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, List, Optional, Pattern, Set, Union
28
+
29
+ from lollmsbot.agent import Tool, ToolResult
30
+
31
+
32
+ @dataclass
33
+ class SecurityPolicy:
34
+ """Security configuration for shell command execution.
35
+
36
+ Attributes:
37
+ allowed_commands: Set of explicitly allowed command names (empty = all allowed).
38
+ denied_commands: Set of denied command names/patterns.
39
+ denied_patterns: List of regex patterns that will reject commands.
40
+ max_timeout: Maximum execution timeout in seconds.
41
+ allowed_working_dirs: Set of directories where commands can execute.
42
+ max_output_size: Maximum output size in bytes to prevent memory issues.
43
+ """
44
+ allowed_commands: Set[str] = field(default_factory=lambda: {
45
+ # Safe network diagnostic tools
46
+ "ping", "ping6",
47
+ "tracert", "traceroute",
48
+ "nslookup", "dig",
49
+ "curl", "wget",
50
+ "netstat", "ss",
51
+ "ip", "ifconfig", "ipconfig",
52
+ "hostname",
53
+ # File operations (read-only)
54
+ "cat", "head", "tail", "less", "more",
55
+ "ls", "dir", "find",
56
+ "grep", "egrep", "fgrep",
57
+ "wc", "sort", "uniq",
58
+ # System info (read-only)
59
+ "ps", "top", "htop", "tasklist",
60
+ "df", "du", "free", "vmstat",
61
+ "uname", "whoami", "id",
62
+ "date", "uptime",
63
+ "echo", "printf",
64
+ # Python (restricted but useful)
65
+ "python", "python3", "py",
66
+ })
67
+ denied_commands: Set[str] = field(default_factory=lambda: {
68
+ "rm", "del", "format", "mkfs", "dd", "shred", "wipe",
69
+ "chmod", "chown", "sudo", "su", "passwd", "shadow",
70
+ "nc", "netcat", "ncat", "telnet",
71
+ "bash", "sh", "zsh", "fish", "cmd", "powershell", "pwsh",
72
+ "perl", "ruby", "node", "php",
73
+ # "python", "python3", # Moved to allowed
74
+ "ssh", "scp", "sftp", "ftp", "rsync",
75
+ "systemctl", "service", "init", "reboot", "shutdown", "halt",
76
+ "kill", "killall", "pkill", "xkill",
77
+ "iptables", "ufw", "firewalld",
78
+ "useradd", "userdel", "groupadd", "groupdel",
79
+ "mount", "umount", "losetup", "modprobe",
80
+ })
81
+ denied_patterns: List[Pattern[str]] = field(default_factory=lambda: [
82
+ re.compile(r"[;&|]\s*(?:rm|del|format|mkfs|dd|chmod|chown|sudo)\b"), # Command chaining
83
+ re.compile(r"`.*?`"), # Backtick substitution
84
+ re.compile(r"\$\(.*?\)"), # Command substitution
85
+ re.compile(r"[><|]\s*/(?:etc|bin|sbin|usr|var|root|proc|sys|dev)"), # Redirection to system paths
86
+ re.compile(r"-[a-zA-Z]*[rf]"), # Force/recursive flags often used destructively
87
+ re.compile(r"\.\./\.\."), # Path traversal attempts
88
+ re.compile(r"(?:https?|ftp|file|data):[/\\]{2}"), # URL-like patterns in commands
89
+ # Block output redirection to files (prevents file writes via shell)
90
+ re.compile(r"[>|]\s*\S+\.(exe|bat|cmd|sh|py|js)$"), # Writing to executable/script files
91
+ re.compile(r"[>|]\s*/"), # Any absolute path redirection
92
+ ])
93
+ max_timeout: float = 30.0
94
+ allowed_working_dirs: Set[Path] = field(default_factory=lambda: {Path.cwd()})
95
+ max_output_size: int = 1024 * 1024 # 1 MB
96
+
97
+
98
+ class ShellTool(Tool):
99
+ """Tool for safe shell command execution with strict security controls.
100
+
101
+ This tool provides controlled shell command execution with multiple
102
+ security layers including allowlist/denylist filtering, timeout
103
+ protection, and output capture. Commands are executed without shell
104
+ interpolation to prevent injection attacks.
105
+
106
+ SECURITY WARNING:
107
+ - Only commands in the allowlist are permitted (if configured)
108
+ - Commands in the denylist are always rejected
109
+ - Commands matching denied patterns are rejected
110
+ - Timeout prevents runaway processes
111
+ - Working directory is restricted
112
+
113
+ Attributes:
114
+ name: Unique identifier for the tool.
115
+ description: Human-readable description of what the tool does.
116
+ parameters: JSON Schema describing expected parameters.
117
+ security: SecurityPolicy instance controlling command validation.
118
+ """
119
+
120
+ name: str = "shell"
121
+ description: str = (
122
+ "Execute safe shell commands with strict security controls. "
123
+ "Commands are validated against allowlist/denylist, executed "
124
+ "with timeout protection, and return stdout, stderr, and return code. "
125
+ "Use with caution - only pre-approved commands are allowed. "
126
+ "Available commands: ping, curl, wget, nslookup, dig, traceroute, "
127
+ "cat, ls, ps, top, df, date, echo, and other read-only diagnostic tools."
128
+ )
129
+
130
+ parameters: dict[str, Any] = {
131
+ "type": "object",
132
+ "properties": {
133
+ "operation": {
134
+ "type": "string",
135
+ "enum": ["execute", "check_allowed"],
136
+ "description": "Operation to perform",
137
+ },
138
+ "command": {
139
+ "type": "string",
140
+ "description": "Shell command to execute (for execute operation)",
141
+ },
142
+ "timeout": {
143
+ "type": "number",
144
+ "description": "Timeout in seconds (optional, max 300)",
145
+ "minimum": 1,
146
+ "maximum": 300,
147
+ },
148
+ "working_dir": {
149
+ "type": "string",
150
+ "description": "Working directory for command execution (optional)",
151
+ },
152
+ "env_vars": {
153
+ "type": "object",
154
+ "description": "Environment variables to set (optional)",
155
+ },
156
+ },
157
+ "required": ["operation"],
158
+ }
159
+
160
+ def __init__(
161
+ self,
162
+ security: Optional[SecurityPolicy] = None,
163
+ default_timeout: float = 30.0,
164
+ ) -> None:
165
+ """Initialize the ShellTool.
166
+
167
+ Args:
168
+ security: SecurityPolicy for command validation. Uses defaults if None.
169
+ default_timeout: Default timeout for command execution in seconds.
170
+ """
171
+ self.security: SecurityPolicy = security or SecurityPolicy()
172
+ self.default_timeout: float = min(default_timeout, 300.0) # Cap at 5 minutes
173
+
174
+ # Compile any additional patterns if provided as strings
175
+ self._ensure_patterns_compiled()
176
+
177
+ def _ensure_patterns_compiled(self) -> None:
178
+ """Ensure all denial patterns are compiled regex objects."""
179
+ compiled_patterns: List[Pattern[str]] = []
180
+ for pattern in self.security.denied_patterns:
181
+ if isinstance(pattern, str):
182
+ compiled_patterns.append(re.compile(pattern))
183
+ else:
184
+ compiled_patterns.append(pattern)
185
+ self.security.denied_patterns = compiled_patterns
186
+
187
+ def check_command_allowed(self, command: str) -> tuple[bool, Optional[str]]:
188
+ """Check if a command is allowed under current security policy.
189
+
190
+ Performs multiple security checks:
191
+ 1. Empty/whitespace check
192
+ 2. Denied pattern matching
193
+ 3. Explicit denylist check
194
+ 4. Explicit allowlist check (if configured)
195
+ 5. Basic injection attempt detection
196
+
197
+ Args:
198
+ command: The command string to validate.
199
+
200
+ Returns:
201
+ Tuple of (is_allowed, reason_if_denied). reason is None if allowed.
202
+ """
203
+ # Check for empty command
204
+ stripped = command.strip()
205
+ if not stripped:
206
+ return False, "Empty command not allowed"
207
+
208
+ # Check for denied patterns (injection attempts, dangerous sequences)
209
+ for pattern in self.security.denied_patterns:
210
+ if pattern.search(stripped):
211
+ return False, f"Command matches denied security pattern: {pattern.pattern}"
212
+
213
+ # Parse command to get base command name
214
+ try:
215
+ # Use shlex to properly parse without executing
216
+ parsed = shlex.split(stripped)
217
+ if not parsed:
218
+ return False, "Could not parse command"
219
+
220
+ base_command = parsed[0]
221
+
222
+ # Remove path prefix if present to get command name
223
+ base_name = Path(base_command).name
224
+
225
+ except ValueError as exc:
226
+ return False, f"Command parsing error: {str(exc)}"
227
+
228
+ # Check explicit denylist
229
+ if base_name in self.security.denied_commands:
230
+ return False, f"Command '{base_name}' is in denylist"
231
+
232
+ # Check if base command is in allowed list (if allowlist is configured)
233
+ if self.security.allowed_commands:
234
+ # Check both full path and base name
235
+ allowed = base_name in self.security.allowed_commands or base_command in self.security.allowed_commands
236
+ if not allowed:
237
+ allowed_list = ", ".join(sorted(self.security.allowed_commands))
238
+ return False, f"Command '{base_name}' not in allowlist. Allowed: {allowed_list}"
239
+
240
+ return True, None
241
+
242
+ def _validate_working_directory(self, working_dir: Optional[str]) -> tuple[Path, Optional[str]]:
243
+ """Validate and resolve working directory.
244
+
245
+ Args:
246
+ working_dir: Requested working directory or None for default.
247
+
248
+ Returns:
249
+ Tuple of (resolved_path, error_message). error is None if valid.
250
+ """
251
+ if working_dir is None:
252
+ # Use first allowed directory as default
253
+ return next(iter(self.security.allowed_working_dirs)), None
254
+
255
+ try:
256
+ requested = Path(working_dir).resolve()
257
+
258
+ # Check if within allowed directories
259
+ for allowed in self.security.allowed_working_dirs:
260
+ try:
261
+ requested.relative_to(allowed)
262
+ return requested, None
263
+ except ValueError:
264
+ continue
265
+
266
+ allowed_strs = [str(d) for d in self.security.allowed_working_dirs]
267
+ return Path.cwd(), f"Working directory '{working_dir}' outside allowed paths: {', '.join(allowed_strs)}"
268
+
269
+ except (OSError, ValueError) as exc:
270
+ return Path.cwd(), f"Invalid working directory '{working_dir}': {str(exc)}"
271
+
272
+ async def execute(
273
+ self,
274
+ command: str,
275
+ timeout: Optional[float] = None,
276
+ working_dir: Optional[str] = None,
277
+ env_vars: Optional[dict[str, str]] = None,
278
+ ) -> ToolResult:
279
+ """Execute a shell command with security checks and timeout protection.
280
+
281
+ SECURITY WARNING: This method executes system commands. All inputs
282
+ are validated against the security policy before execution.
283
+
284
+ Args:
285
+ command: The command string to execute.
286
+ timeout: Maximum execution time in seconds. Uses default if None.
287
+ working_dir: Working directory for execution. Must be in allowed list.
288
+ env_vars: Additional environment variables for the process.
289
+
290
+ Returns:
291
+ ToolResult with stdout, stderr, return code, and execution metadata.
292
+ """
293
+ # Validate command against security policy
294
+ allowed, reason = self.check_command_allowed(command)
295
+ if not allowed:
296
+ return ToolResult(
297
+ success=False,
298
+ output=None,
299
+ error=f"Security check failed: {reason}",
300
+ )
301
+
302
+ # Validate working directory
303
+ work_dir, dir_error = self._validate_working_directory(working_dir)
304
+ if dir_error:
305
+ return ToolResult(
306
+ success=False,
307
+ output=None,
308
+ error=f"Directory validation failed: {dir_error}",
309
+ )
310
+
311
+ # Validate timeout
312
+ exec_timeout = min(timeout or self.default_timeout, 300.0)
313
+
314
+ # Prepare environment
315
+ process_env: Optional[dict[str, str]] = None
316
+ if env_vars:
317
+ import os
318
+ process_env = {**os.environ, **env_vars}
319
+
320
+ # Parse command safely using shlex
321
+ try:
322
+ cmd_args = shlex.split(command)
323
+ except ValueError as exc:
324
+ return ToolResult(
325
+ success=False,
326
+ output=None,
327
+ error=f"Failed to parse command: {str(exc)}",
328
+ )
329
+
330
+ # Execute with timeout using asyncio
331
+ import time
332
+ start_time = time.time()
333
+
334
+ try:
335
+ # Create subprocess without shell=True for security
336
+ process = await asyncio.create_subprocess_exec(
337
+ *cmd_args,
338
+ stdout=asyncio.subprocess.PIPE,
339
+ stderr=asyncio.subprocess.PIPE,
340
+ cwd=work_dir,
341
+ env=process_env,
342
+ )
343
+
344
+ # Wait for completion with timeout
345
+ try:
346
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
347
+ process.communicate(),
348
+ timeout=exec_timeout,
349
+ )
350
+ except asyncio.TimeoutError:
351
+ # Kill the process if it times out
352
+ try:
353
+ process.kill()
354
+ await process.wait()
355
+ except ProcessLookupError:
356
+ pass # Already exited
357
+
358
+ return ToolResult(
359
+ success=False,
360
+ output=None,
361
+ error=f"Command timed out after {exec_timeout} seconds",
362
+ execution_time=time.time() - start_time,
363
+ )
364
+
365
+ execution_time = time.time() - start_time
366
+
367
+ # Decode output with size limit
368
+ def decode_limited(data: bytes, max_size: int) -> str:
369
+ if len(data) > max_size:
370
+ truncated = data[:max_size]
371
+ decoded = truncated.decode("utf-8", errors="replace")
372
+ return decoded + f"\n[TRUNCATED: {len(data) - max_size} bytes omitted]"
373
+ return data.decode("utf-8", errors="replace")
374
+
375
+ max_size = self.security.max_output_size
376
+ stdout = decode_limited(stdout_bytes, max_size // 2)
377
+ stderr = decode_limited(stderr_bytes, max_size // 2)
378
+
379
+ # Build result
380
+ result_data = {
381
+ "command": command,
382
+ "return_code": process.returncode,
383
+ "stdout": stdout,
384
+ "stderr": stderr,
385
+ "stdout_bytes": len(stdout_bytes),
386
+ "stderr_bytes": len(stderr_bytes),
387
+ "execution_time": execution_time,
388
+ "working_directory": str(work_dir),
389
+ }
390
+
391
+ # Consider non-zero return code as failure
392
+ success = process.returncode == 0
393
+
394
+ return ToolResult(
395
+ success=success,
396
+ output=result_data,
397
+ error=stderr if not success and stderr else None,
398
+ execution_time=execution_time,
399
+ )
400
+
401
+ except FileNotFoundError as exc:
402
+ return ToolResult(
403
+ success=False,
404
+ output=None,
405
+ error=f"Command not found: {exc.filename}",
406
+ execution_time=time.time() - start_time,
407
+ )
408
+ except PermissionError as exc:
409
+ return ToolResult(
410
+ success=False,
411
+ output=None,
412
+ error=f"Permission denied executing command: {str(exc)}",
413
+ execution_time=time.time() - start_time,
414
+ )
415
+ except Exception as exc:
416
+ return ToolResult(
417
+ success=False,
418
+ output=None,
419
+ error=f"Execution error: {str(exc)}",
420
+ execution_time=time.time() - start_time,
421
+ )
422
+
423
+ async def check_allowed(self, command: str) -> ToolResult:
424
+ """Check if a command would be allowed without executing it.
425
+
426
+ Args:
427
+ command: The command string to check.
428
+
429
+ Returns:
430
+ ToolResult with check results and security policy info.
431
+ """
432
+ allowed, reason = self.check_command_allowed(command)
433
+
434
+ result = {
435
+ "command": command,
436
+ "allowed": allowed,
437
+ "reason": reason,
438
+ "security_policy": {
439
+ "allowlist_enabled": bool(self.security.allowed_commands),
440
+ "allowed_commands_count": len(self.security.allowed_commands),
441
+ "denied_commands_count": len(self.security.denied_commands),
442
+ "denied_patterns_count": len(self.security.denied_patterns),
443
+ "max_timeout": self.security.max_timeout,
444
+ "allowed_working_dirs": [str(d) for d in self.security.allowed_working_dirs],
445
+ },
446
+ }
447
+
448
+ return ToolResult(
449
+ success=allowed,
450
+ output=result,
451
+ error=reason if not allowed else None,
452
+ )
453
+
454
+ async def execute_tool(self, **params: Any) -> ToolResult:
455
+ """Execute shell tool operation based on parameters.
456
+
457
+ Main entry point for Tool base class. Dispatches to appropriate
458
+ method based on the 'operation' parameter.
459
+
460
+ Args:
461
+ **params: Parameters must include:
462
+ - operation: 'execute' or 'check_allowed'
463
+ - command: Required for both operations
464
+ - timeout: Optional for execute
465
+ - working_dir: Optional for execute
466
+ - env_vars: Optional for execute
467
+
468
+ Returns:
469
+ ToolResult from the executed operation.
470
+ """
471
+ operation = params.get("operation")
472
+
473
+ if not operation:
474
+ return ToolResult(
475
+ success=False,
476
+ output=None,
477
+ error="Missing required parameter: 'operation'",
478
+ )
479
+
480
+ command = params.get("command")
481
+ if not command:
482
+ return ToolResult(
483
+ success=False,
484
+ output=None,
485
+ error="Missing required parameter: 'command'",
486
+ )
487
+
488
+ if operation == "execute":
489
+ return await self.execute(
490
+ command=command,
491
+ timeout=params.get("timeout"),
492
+ working_dir=params.get("working_dir"),
493
+ env_vars=params.get("env_vars"),
494
+ )
495
+
496
+ elif operation == "check_allowed":
497
+ return await self.check_allowed(command)
498
+
499
+ else:
500
+ return ToolResult(
501
+ success=False,
502
+ output=None,
503
+ error=f"Unknown operation: '{operation}'. Valid operations: execute, check_allowed",
504
+ )
505
+
506
+ # Alias for Tool base class compatibility
507
+ async def execute(self, **params: Any) -> ToolResult:
508
+ """Compatibility method for Tool base class.
509
+
510
+ Delegates to execute_tool for actual implementation.
511
+ """
512
+ return await self.execute_tool(**params)
513
+
514
+ def __repr__(self) -> str:
515
+ return (
516
+ f"ShellTool(allowed={len(self.security.allowed_commands)}, "
517
+ f"denied={len(self.security.denied_commands)}, "
518
+ f"timeout={self.default_timeout})"
519
+ )
@@ -0,0 +1,11 @@
1
+ """
2
+ Web UI package for LollmsBot.
3
+
4
+ Provides a beautiful local web interface for interacting with the AI agent.
5
+ Includes real-time chat, conversation history, and tool execution visualization.
6
+ """
7
+
8
+ from lollmsbot.ui.app import WebUI
9
+ from lollmsbot.ui.routes import ui_router
10
+
11
+ __all__ = ["WebUI", "ui_router"]
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Run the LollmsBot Web UI as a standalone module.
4
+ """
5
+ import uvicorn
6
+ from lollmsbot.ui.app import WebUI
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.layout import Layout
10
+ from rich.table import Table
11
+ from rich import box
12
+
13
+ console = Console()
14
+
15
+ def create_startup_panel(host: str, port: int) -> Panel:
16
+ """Create a beautiful startup information panel."""
17
+
18
+ # Create feature table
19
+ feature_table = Table(
20
+ show_header=False,
21
+ box=None,
22
+ padding=(0, 2),
23
+ collapse_padding=True
24
+ )
25
+ feature_table.add_column("Icon", style="cyan", justify="center")
26
+ feature_table.add_column("Feature", style="white")
27
+
28
+ features = [
29
+ ("⚡", "Real-time WebSocket chat"),
30
+ ("🎨", "Dark modern interface"),
31
+ ("🔧", "4 Built-in tools (Files, HTTP, Calendar, Shell)"),
32
+ ("⚙️", "In-browser settings"),
33
+ ("📱", "Mobile-responsive design"),
34
+ ("🔄", "Auto-reconnect on connection loss"),
35
+ ]
36
+
37
+ for icon, feature in features:
38
+ feature_table.add_row(icon, feature)
39
+
40
+ # Create access URLs table
41
+ urls_table = Table(
42
+ show_header=True,
43
+ header_style="bold magenta",
44
+ box=box.SIMPLE,
45
+ border_style="blue",
46
+ padding=(0, 1)
47
+ )
48
+ urls_table.add_column("Access From", style="cyan")
49
+ urls_table.add_column("URL", style="green link")
50
+
51
+ local_url = f"http://localhost:{port}"
52
+ network_url = f"http://{host}:{port}"
53
+ ws_url = f"ws://{host}:{port}/ws/chat"
54
+
55
+ urls_table.add_row("This Computer", local_url)
56
+ if host not in ("127.0.0.1", "localhost"):
57
+ urls_table.add_row("Network Devices", network_url)
58
+ urls_table.add_row("WebSocket Endpoint", ws_url)
59
+
60
+ # Combine everything
61
+ layout = Layout()
62
+ layout.split_column(
63
+ Layout(feature_table, name="features"),
64
+ Layout(urls_table, name="urls")
65
+ )
66
+
67
+ # Add tips at the bottom
68
+ tips = """
69
+ [dim]💡 Pro Tips:
70
+ • Press [bold yellow]Ctrl+C[/bold yellow] to stop the server gracefully
71
+ • The UI auto-creates CSS/JS files on first run if missing
72
+ • Open multiple browser tabs to test multi-user scenarios
73
+ • Use the ⚙️ icon in top-right to customize LoLLMS connection[/dim]
74
+ """
75
+
76
+ panel_content = f"""
77
+ [bold blue]🤖 LollmsBot Web Interface[/bold blue]
78
+
79
+ {layout.tree}
80
+ {tips}
81
+ """
82
+
83
+ return Panel(
84
+ panel_content.strip(),
85
+ box=box.DOUBLE_EDGE,
86
+ border_style="bright_green",
87
+ title="[bold bright_green]🚀 Starting Server[/bold bright_green]",
88
+ subtitle=f"[dim]v0.1.0 | Python 3.10+ | FastAPI + WebSocket[/dim]"
89
+ )
90
+
91
+ if __name__ == "__main__":
92
+ import argparse
93
+
94
+ parser = argparse.ArgumentParser(description="LollmsBot Web UI")
95
+ parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
96
+ parser.add_argument("--port", type=int, default=8080, help="Port to listen on")
97
+ parser.add_argument("--quiet", "-q", action="store_true", help="Minimal output")
98
+ args = parser.parse_args()
99
+
100
+ if not args.quiet:
101
+ console.print()
102
+ console.print(create_startup_panel(args.host, args.port))
103
+ console.print()
104
+
105
+ # Create UI instance (this prints its own banner)
106
+ ui = WebUI(verbose=not args.quiet)
107
+
108
+ # Print server ready message
109
+ if not args.quiet:
110
+ ui.print_server_ready(args.host, args.port)
111
+
112
+ # Run server
113
+ try:
114
+ uvicorn.run(
115
+ ui.app,
116
+ host=args.host,
117
+ port=args.port,
118
+ log_level="warning" if args.quiet else "info"
119
+ )
120
+ except KeyboardInterrupt:
121
+ ui._print_shutdown_message()