diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index d44b8c06ea..ef22160727 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -46,6 +46,26 @@ jobs: python -m pip install --upgrade pip python -m pip install uv + - name: Install Linux sandbox dependency + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y bubblewrap + + - name: Enable and verify Linux sandbox execution + if: runner.os == 'Linux' + run: | + # Ubuntu's AppArmor policy blocks user-namespace capabilities by default. + # Allow them on this ephemeral runner so the real sandbox tests can run. + if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + bwrap --unshare-all --ro-bind / / /bin/true + + - name: Verify macOS sandbox dependency + if: runner.os == 'macOS' + run: test -x /usr/bin/sandbox-exec + - name: Run tests run: | chmod +x scripts/run_pytests_ci.sh diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 4a77c8a164..fb2329aebd 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -1,20 +1,24 @@ from __future__ import annotations import asyncio -import hashlib import locale import os import shutil import signal import subprocess import sys +import tempfile +import threading import time import uuid +from _thread import LockType +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, BinaryIO, cast if sys.version_info < (3, 14): + import python_ripgrep from python_ripgrep import search from astrbot.api import logger @@ -22,6 +26,12 @@ detect_text_encoding, read_local_text_range_sync, ) +from astrbot.core.computer.process_sandbox import ( + SandboxProcess, + SandboxSpec, + SandboxTimeoutError, + create_process_sandbox, +) from astrbot.core.utils.astrbot_path import ( get_astrbot_root, get_astrbot_system_tmp_path, @@ -46,6 +56,25 @@ " kill -9 ", " killall ", ] +_LOCAL_SANDBOX_MAX_OUTPUT_BYTES = 10 * 1024 * 1024 +_SANDBOXED_PYTHON_RIPGREP = """ +import sys + +sys.path.insert(0, sys.argv[1]) +from python_ripgrep import search + +after_context = int(sys.argv[5]) if sys.argv[5] else None +before_context = int(sys.argv[6]) if sys.argv[6] else None +results = search( + patterns=[sys.argv[2]], + paths=[sys.argv[3]] if sys.argv[3] else None, + globs=[sys.argv[4]] if sys.argv[4] else None, + after_context=after_context, + before_context=before_context, + line_number=True, +) +sys.stdout.write("".join(results)) +""" def _is_safe_command(command: str) -> bool: @@ -112,16 +141,19 @@ class _LocalShellSession: creator_id: str creator_is_admin: bool sandboxed: bool - process: asyncio.subprocess.Process - output_path: Path + process: SandboxProcess | asyncio.subprocess.Process + output_file: BinaryIO + output_lock: LockType started_at: float output_event: asyncio.Event reader_task: asyncio.Task[None] wait_task: asyncio.Task[int] + permission_check: Callable[[], bool] | None = None timeout_task: asyncio.Task[None] | None = None cursor: int = 0 timed_out: bool = False terminated: bool = False + output_limited: bool = False @dataclass @@ -236,6 +268,11 @@ async def exec_managed( creator_id: str, creator_is_admin: bool, sandboxed: bool, + permission_check: Callable[[], bool], + allow_network: bool = False, + filesystem_scope: str = "workspace", + readable_roots: tuple[Path, ...] = (), + writable_roots: tuple[Path, ...] = (), cwd: str | None = None, env: dict[str, str] | None = None, timeout: int | None = None, @@ -250,6 +287,11 @@ async def exec_managed( creator_id: Sender ID that created the session. creator_is_admin: Whether the creator was an administrator. sandboxed: Whether the process is isolated from the host. + permission_check: Check that the creation permissions still apply. + allow_network: Whether an isolated process may access the network. + filesystem_scope: Filesystem scope applied to an isolated process. + readable_roots: Additional directories readable by an isolated process. + writable_roots: Additional directories writable by an isolated process. cwd: Working directory for the process. env: Additional environment variables. timeout: Hard process lifetime in seconds. None disables it. @@ -260,7 +302,8 @@ async def exec_managed( Process result with output, status, and session metadata. Raises: - PermissionError: If the command matches a blocked pattern. + PermissionError: If the command is blocked or its permissions changed. + RuntimeError: If the requested platform sandbox is unavailable. ValueError: If a timing or output limit is invalid. """ if not _is_safe_command(command): @@ -272,117 +315,152 @@ async def exec_managed( if max_output_chars < 1: raise ValueError("`max_output_chars` must be greater than 0.") - run_env = os.environ.copy() - if env: - run_env.update({str(k): str(v) for k, v in env.items()}) - if sys.platform == "win32": - # Keep managed-session child output UTF-8 (see LocalShellComponent.exec). - run_env.setdefault("PYTHONIOENCODING", "utf-8") working_dir = Path(cwd).resolve() if cwd else Path(get_astrbot_root()).resolve() session_id = f"sh_{uuid.uuid4().hex[:16]}" - owner_digest = hashlib.sha256(owner_id.encode("utf-8")).hexdigest()[:16] - output_dir = Path(get_astrbot_system_tmp_path()) / "shell" / owner_digest + output_dir = Path(get_astrbot_system_tmp_path()) output_dir.mkdir(parents=True, exist_ok=True) - output_path = output_dir / f"{session_id}.log" - output_path.touch() - - process_kwargs: dict[str, Any] = {} - if sys.platform == "win32": - process_kwargs["creationflags"] = getattr( - subprocess, - "CREATE_NEW_PROCESS_GROUP", - 0, - ) - else: - process_kwargs["start_new_session"] = True - - try: - if sys.platform == "win32": - process_factory = asyncio.create_subprocess_exec - shell_executable = resolve_windows_shell() - process_args = ( - shell_executable, - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - command, + # Configuration invalidation must also see processes still being spawned. + async with self._sessions_lock: + if not permission_check(): + raise PermissionError( + "Local shell permissions changed; retry the command." ) - else: - process_factory = asyncio.create_subprocess_shell - process_args = (command,) - process = await process_factory( - *process_args, - cwd=working_dir, - env=run_env, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - **process_kwargs, - ) - except Exception: - output_path.unlink(missing_ok=True) - raise + # Shared temporary roots are writable by sandboxed processes. Keep + # output on an anonymous handle to prevent redirecting host I/O. + output_file = tempfile.TemporaryFile(mode="w+b", dir=output_dir) + output_lock = threading.Lock() + try: + if sandboxed: + process = await create_process_sandbox().spawn_shell( + command, + SandboxSpec( + workspace=working_dir, + allow_network=allow_network, + filesystem_scope=filesystem_scope, + readable_roots=readable_roots, + writable_roots=writable_roots, + ), + env={str(k): str(v) for k, v in (env or {}).items()}, + ) + else: + run_env = os.environ.copy() + if env: + run_env.update({str(k): str(v) for k, v in env.items()}) + process_kwargs: dict[str, Any] = {} + if sys.platform == "win32": + # Keep managed-session Python output UTF-8. + run_env.setdefault("PYTHONIOENCODING", "utf-8") + process_factory = asyncio.create_subprocess_exec + shell_executable = resolve_windows_shell() + process_args = ( + shell_executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ) + process_kwargs["creationflags"] = getattr( + subprocess, + "CREATE_NEW_PROCESS_GROUP", + 0, + ) + else: + process_factory = asyncio.create_subprocess_shell + process_args = (command,) + process_kwargs["start_new_session"] = True + process = await process_factory( + *process_args, + cwd=working_dir, + env=run_env, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + **process_kwargs, + ) + except BaseException: + output_file.close() + raise - output_event = asyncio.Event() + output_event = asyncio.Event() - async def _capture_output() -> None: - if process.stdout is None: - return - with output_path.open("ab") as output_file: + async def _capture_output() -> None: + if process.stdout is None: + return + output_size = 0 while chunk := await process.stdout.read(8192): - output_file.write(chunk) - output_file.flush() + if sandboxed: + remaining = _LOCAL_SANDBOX_MAX_OUTPUT_BYTES - output_size + if remaining <= 0: + session.output_limited = True + process.terminate() + return + if len(chunk) > remaining: + chunk = chunk[:remaining] + session.output_limited = True + with output_lock: + output_file.seek(0, os.SEEK_END) + output_file.write(chunk) + output_file.flush() + output_size += len(chunk) output_event.set() + if session.output_limited: + process.terminate() + return - reader_task = asyncio.create_task( - _capture_output(), - name=f"local_shell_output_{session_id}", - ) - wait_task = asyncio.create_task( - process.wait(), - name=f"local_shell_wait_{session_id}", - ) - wait_task.add_done_callback(lambda _: output_event.set()) - session = _LocalShellSession( - session_id=session_id, - owner_id=owner_id, - creator_id=creator_id, - creator_is_admin=creator_is_admin, - sandboxed=sandboxed, - process=process, - output_path=output_path, - started_at=time.time(), - output_event=output_event, - reader_task=reader_task, - wait_task=wait_task, - ) + reader_task = asyncio.create_task( + _capture_output(), + name=f"local_shell_output_{session_id}", + ) + wait_task = asyncio.create_task( + process.wait(), + name=f"local_shell_wait_{session_id}", + ) + wait_task.add_done_callback(lambda _: output_event.set()) + session = _LocalShellSession( + session_id=session_id, + owner_id=owner_id, + creator_id=creator_id, + creator_is_admin=creator_is_admin, + sandboxed=sandboxed, + process=process, + output_file=output_file, + output_lock=output_lock, + started_at=time.time(), + output_event=output_event, + reader_task=reader_task, + wait_task=wait_task, + permission_check=permission_check, + ) - if timeout is not None: + if timeout is not None: - async def _enforce_timeout() -> None: - try: - await asyncio.wait_for( - asyncio.shield(wait_task), - timeout=timeout, - ) - except asyncio.TimeoutError: - session.timed_out = True - logger.warning( - "Managed local shell session timed out: session_id=%s pid=%s", - session_id, - process.pid, - ) - await self._terminate_process(session) + async def _enforce_timeout() -> None: + try: + await asyncio.wait_for( + asyncio.shield(wait_task), + timeout=timeout, + ) + except asyncio.TimeoutError: + session.timed_out = True + logger.warning( + "Managed local shell session timed out: session_id=%s pid=%s", + session_id, + process.pid, + ) + await self._terminate_process(session) - session.timeout_task = asyncio.create_task( - _enforce_timeout(), - name=f"local_shell_timeout_{session_id}", - ) + session.timeout_task = asyncio.create_task( + _enforce_timeout(), + name=f"local_shell_timeout_{session_id}", + ) - async with self._sessions_lock: self._sessions[session_id] = session + if not permission_check(): + await self.shutdown_sessions(invalid_only=True) + raise PermissionError("Local shell permissions changed; retry the command.") + if yield_time_ms > 0: try: await asyncio.wait_for( @@ -443,15 +521,19 @@ async def list_sessions( "timed_out" if session.timed_out else ( - "terminated" - if session.terminated - else ("completed" if exit_code == 0 else "failed") + "output_limited" + if session.output_limited + else ( + "terminated" + if session.terminated + else ("completed" if exit_code == 0 else "failed") + ) ) ) ) try: - output_size = session.output_path.stat().st_size - except OSError: + output_size = os.fstat(session.output_file.fileno()).st_size + except (OSError, ValueError): output_size = session.cursor items.append( { @@ -510,14 +592,13 @@ async def poll_session( raise ValueError("`cursor` must be greater than or equal to 0.") def _read_output() -> tuple[bytes, int, int]: - try: - output_size = session.output_path.stat().st_size - except FileNotFoundError: - return b"", read_cursor, read_cursor - normalized_cursor = min(read_cursor, output_size) - with session.output_path.open("rb") as output_file: - output_file.seek(normalized_cursor) - raw_output = output_file.read(max_output_chars) + with session.output_lock: + if session.output_file.closed: + return b"", read_cursor, read_cursor + output_size = os.fstat(session.output_file.fileno()).st_size + normalized_cursor = min(read_cursor, output_size) + session.output_file.seek(normalized_cursor) + raw_output = session.output_file.read(max_output_chars) return ( raw_output, normalized_cursor + len(raw_output), @@ -568,9 +649,13 @@ def _read_output() -> tuple[bytes, int, int]: "timed_out" if session.timed_out else ( - "terminated" - if session.terminated - else ("completed" if exit_code == 0 else "failed") + "output_limited" + if session.output_limited + else ( + "terminated" + if session.terminated + else ("completed" if exit_code == 0 else "failed") + ) ) ) ) @@ -621,7 +706,11 @@ async def write_session( requester_is_admin, session_id, ) - if session.process.returncode is not None or session.process.stdin is None: + if ( + session.terminated + or session.process.returncode is not None + or session.process.stdin is None + ): raise ValueError(f"Shell session {session_id} is not accepting input.") session.process.stdin.write(chars.encode("utf-8")) await session.process.stdin.drain() @@ -662,7 +751,9 @@ async def interrupt_session( session_id, ) if session.process.returncode is None: - if os.name == "nt": + if session.sandboxed: + cast(SandboxProcess, session.process).interrupt() + elif os.name == "nt": session.process.send_signal( getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM) ) @@ -718,12 +809,22 @@ async def terminate_session( max_output_chars=max_output_chars, ) - async def shutdown_sessions(self) -> None: - """Terminate and remove every managed local shell session.""" + async def shutdown_sessions(self, *, invalid_only: bool = False) -> None: + """Terminate and remove managed local shell sessions. + + Args: + invalid_only: Keep sessions whose creation permissions still apply. + """ async with self._sessions_lock: - sessions = list(self._sessions.values()) - for session in sessions: - session.terminated = True + sessions = [ + session + for session in self._sessions.values() + if not invalid_only + or getattr(session, "permission_check", None) is None + or not session.permission_check() + ] + for session in sessions: + session.terminated = True termination_results = await asyncio.gather( *(self._terminate_process(session) for session in sessions), return_exceptions=True, @@ -761,7 +862,7 @@ async def _get_owned_session( Matching managed shell session. Raises: - ValueError: If the session does not exist for this owner. + ValueError: If the session is unavailable or its permissions changed. """ async with self._sessions_lock: session = self._sessions.get(session_id) @@ -773,7 +874,19 @@ async def _get_owned_session( and (session.creator_is_admin or session.creator_id != requester_id) ) ): - raise ValueError(f"Shell session {session_id} was not found.") + raise ValueError( + f"Shell session {session_id} was not found or has expired. " + "Start a new shell session." + ) + if ( + getattr(session, "permission_check", None) is None + or not session.permission_check() + ): + await self.shutdown_sessions(invalid_only=True) + raise ValueError( + f"Shell session {session_id} expired after a permission change. " + "Start a new shell session." + ) return session async def _terminate_process(self, session: _LocalShellSession) -> None: @@ -782,9 +895,11 @@ async def _terminate_process(self, session: _LocalShellSession) -> None: Args: session: Managed shell session to terminate. """ - if session.process.returncode is not None: + if os.name == "nt" and session.process.returncode is not None: return - if os.name == "nt": + if session.sandboxed: + session.process.terminate() + elif os.name == "nt": try: taskkill_result = await asyncio.to_thread( subprocess.run, @@ -810,14 +925,19 @@ async def _terminate_process(self, session: _LocalShellSession) -> None: timeout=5, ) except asyncio.TimeoutError: - if os.name == "nt": + pass + # The leader may have exited while children remain in its process group. + if session.sandboxed: + session.process.kill() + elif os.name == "nt": + if session.process.returncode is None: session.process.kill() - else: - try: - os.killpg(session.process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - await session.wait_task + else: + try: + os.killpg(session.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await session.wait_task async def _remove_session(self, session: _LocalShellSession) -> None: """Remove a completed session and its temporary output file. @@ -839,11 +959,8 @@ async def _remove_session(self, session: _LocalShellSession) -> None: await timeout_task except asyncio.CancelledError: pass - session.output_path.unlink(missing_ok=True) - try: - session.output_path.parent.rmdir() - except OSError: - pass + with session.output_lock: + session.output_file.close() @dataclass @@ -855,34 +972,93 @@ async def exec( timeout: int = 30, silent: bool = False, cwd: str | None = None, + sandboxed: bool = False, + allow_network: bool = False, + filesystem_scope: str = "workspace", + readable_roots: tuple[Path, ...] = (), + writable_roots: tuple[Path, ...] = (), ) -> dict[str, Any]: + """Execute Python locally, optionally inside the platform sandbox. + + Args: + code: Python source to execute. + kernel_id: Reserved kernel identifier for protocol compatibility. + timeout: Hard execution timeout in seconds. + silent: Whether to suppress standard output. + cwd: Working directory for the process. + sandboxed: Whether to isolate execution with the platform sandbox. + allow_network: Whether an isolated process may access the network. + filesystem_scope: Filesystem scope applied to an isolated process. + readable_roots: Additional directories readable by an isolated process. + writable_roots: Additional directories writable by an isolated process. + + Returns: + Python output and error data in the computer component format. + """ + def _run() -> dict[str, Any]: try: - working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root() - child_env = os.environ.copy() - if sys.platform == "win32": - # Keep python tool output UTF-8 (see LocalShellComponent.exec). - child_env.setdefault("PYTHONIOENCODING", "utf-8") - result = subprocess.run( - [os.environ.get("PYTHON", sys.executable), "-c", code], - timeout=timeout, - capture_output=True, - cwd=working_dir, - env=child_env, + working_dir = ( + Path(cwd).resolve() if cwd else Path(get_astrbot_root()).resolve() ) - stdout = "" if silent else _decode_shell_output(result.stdout) - stderr = ( - _decode_shell_output(result.stderr) - if result.returncode != 0 + if sandboxed: + sandbox = create_process_sandbox() + result = sandbox.run( + [sys.executable, "-c", code], + SandboxSpec( + workspace=working_dir, + allow_network=allow_network, + filesystem_scope=filesystem_scope, + readable_roots=readable_roots, + writable_roots=writable_roots, + ), + timeout=timeout, + output_limit=_LOCAL_SANDBOX_MAX_OUTPUT_BYTES, + discard_stdout=silent, + ) + stdout = _decode_shell_output(result.stdout) + stderr = _decode_shell_output(result.stderr) + stdout_limited = result.stdout_limited + stderr_limited = result.stderr_limited + else: + child_env = os.environ.copy() + if sys.platform == "win32": + # Keep Python tool output UTF-8. + child_env.setdefault("PYTHONIOENCODING", "utf-8") + run_command = [ + os.environ.get("PYTHON", sys.executable), + "-c", + code, + ] + result = subprocess.run( + run_command, + timeout=timeout, + capture_output=True, + cwd=working_dir, + env=child_env, + ) + stdout = "" if silent else _decode_shell_output(result.stdout) + stderr = _decode_shell_output(result.stderr) + stdout_limited = False + stderr_limited = False + if stdout_limited or stderr_limited: + limit_error = ( + "Execution output exceeded " + f"{_LOCAL_SANDBOX_MAX_OUTPUT_BYTES} bytes." + ) + stderr = f"{stderr}\n{limit_error}".strip() + execution_error = ( + stderr + if result.returncode != 0 or stdout_limited or stderr_limited else "" ) return { "data": { "output": {"text": stdout, "images": []}, - "error": stderr, + "error": execution_error, } } - except subprocess.TimeoutExpired: + except (SandboxTimeoutError, subprocess.TimeoutExpired): return { "data": { "output": {"text": "", "images": []}, @@ -941,9 +1117,11 @@ async def search_files( glob: str | None = None, after_context: int | None = None, before_context: int | None = None, + sandboxed: bool = False, + sandbox_root: str | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: - if sys.version_info < (3, 14): + if not sandboxed and sys.version_info < (3, 14): results = search( patterns=[pattern], paths=[path] if path else None, @@ -957,34 +1135,78 @@ def _run() -> dict[str, Any]: "content": _truncate_long_lines("".join(results)), } - rg_path = shutil.which("rg") - if not rg_path: - return { - "success": False, - "content": "", - "error": ( - "The ripgrep (rg) executable is required for file search on " - "Python 3.14 or later because python-ripgrep 0.0.8 is " - "incompatible." - ), - } + if sandboxed and sys.version_info < (3, 14): + site_packages = str( + Path(python_ripgrep.__file__).resolve().parent.parent + ) + command = [ + sys.executable, + "-I", + "-S", + "-c", + _SANDBOXED_PYTHON_RIPGREP, + site_packages, + pattern, + path or "", + glob or "", + "" if after_context is None else str(after_context), + "" if before_context is None else str(before_context), + ] + else: + rg_path = shutil.which("rg") + if not rg_path: + return { + "success": False, + "content": "", + "error": ( + "The ripgrep (rg) executable is required for file search " + "on Python 3.14 or later because python-ripgrep 0.0.8 is " + "incompatible." + ), + } - command = [rg_path, "--color=never", "-n", "-e", pattern] - if glob: - command.extend(["-g", glob]) - if after_context is not None: - command.extend(["-A", str(after_context)]) - if before_context is not None: - command.extend(["-B", str(before_context)]) - command.extend(["--", path or "."]) + command = [ + str(Path(rg_path).resolve()) if sandboxed else rg_path, + "--color=never", + "-n", + "-e", + pattern, + ] + if glob: + command.extend(["-g", glob]) + if after_context is not None: + command.extend(["-A", str(after_context)]) + if before_context is not None: + command.extend(["-B", str(before_context)]) + command.extend(["--", path or "."]) + sandbox_workspace: Path | None = None + if sandboxed: + if not sandbox_root: + return { + "success": False, + "content": "", + "error": "A sandbox root is required for restricted Local search.", + } + sandbox_workspace = Path(sandbox_root) try: - result = subprocess.run( - command, - capture_output=True, - timeout=30, - ) - except subprocess.TimeoutExpired: + if sandboxed: + assert sandbox_workspace is not None + result = create_process_sandbox().run( + command, + SandboxSpec( + workspace=sandbox_workspace, + workspace_writable=False, + ), + timeout=30, + ) + else: + result = subprocess.run( + command, + capture_output=True, + timeout=30, + ) + except (SandboxTimeoutError, subprocess.TimeoutExpired): return { "success": False, "content": "", @@ -1027,24 +1249,43 @@ async def edit_file( new_string: str, replace_all: bool = False, encoding: str = "utf-8", + file_descriptor: int | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = os.path.abspath(path) - with open(abs_path, encoding=encoding) as f: - content = f.read() - occurrences = content.count(old_string) - if occurrences == 0: - return { - "success": False, - "error": "old string not found in file", - "replacements": 0, - } - if replace_all: - updated = content.replace(old_string, new_string) - replacements = occurrences + if file_descriptor is None: + file_obj = open(abs_path, encoding=encoding) else: - updated = content.replace(old_string, new_string, 1) - replacements = 1 + file_obj = os.fdopen( + os.dup(file_descriptor), + mode="r+", + encoding=encoding, + ) + file_obj.seek(0) + with file_obj as f: + content = f.read() + occurrences = content.count(old_string) + if occurrences == 0: + return { + "success": False, + "error": "old string not found in file", + "replacements": 0, + } + if replace_all: + updated = content.replace(old_string, new_string) + replacements = occurrences + else: + updated = content.replace(old_string, new_string, 1) + replacements = 1 + if file_descriptor is not None: + f.seek(0) + f.truncate() + f.write(updated) + return { + "success": True, + "path": abs_path, + "replacements": replacements, + } with open(abs_path, "w", encoding=encoding) as f: f.write(updated) return { @@ -1056,12 +1297,30 @@ def _run() -> dict[str, Any]: return await asyncio.to_thread(_run) async def write_file( - self, path: str, content: str, mode: str = "w", encoding: str = "utf-8" + self, + path: str, + content: str, + mode: str = "w", + encoding: str = "utf-8", + file_descriptor: int | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = os.path.abspath(path) - os.makedirs(os.path.dirname(abs_path), exist_ok=True) - with open(abs_path, mode, encoding=encoding) as f: + if file_descriptor is None: + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + file_obj = open(abs_path, mode, encoding=encoding) + else: + file_obj = os.fdopen( + os.dup(file_descriptor), + mode=mode, + encoding=encoding, + ) + if mode == "w": + file_obj.seek(0) + file_obj.truncate() + elif mode == "a": + file_obj.seek(0, os.SEEK_END) + with file_obj as f: f.write(content) return {"success": True, "path": abs_path} @@ -1100,7 +1359,7 @@ def __init__(self) -> None: async def boot(self, session_id: str) -> None: logger.info(f"Local computer booter initialized for session: {session_id}") - async def shutdown(self) -> None: + async def shutdown(self, **_kwargs: Any) -> None: await self._shell.shutdown_sessions() logger.info("Local computer booter shutdown complete.") diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py index 3590446709..0e6b2fce1a 100644 --- a/astrbot/core/computer/file_read_utils.py +++ b/astrbot/core/computer/file_read_utils.py @@ -4,6 +4,7 @@ import hashlib import io import json +import os import zipfile from asyncio import to_thread from dataclasses import dataclass @@ -24,6 +25,7 @@ ) from .booters.base import ComputerBooter +from .local_file_security import open_file_in_allowed_roots _MAX_FILE_READ_BYTES = 128 * 1024 _MAX_FILE_READ_TOKENS = 25_000 @@ -210,13 +212,22 @@ def read_local_text_range_sync( encoding: str, offset: int | None, limit: int | None, + file_descriptor: int | None = None, ) -> str: lines: list[str] = [] start = 0 if offset is None else offset end = None if limit is None else start + limit - # Default universal newlines so CRLF files read back with "\n" on every - # platform. - with open(path, encoding=encoding) as file_obj: + # Normalize CRLF with universal newlines for both paths and safe handles. + if file_descriptor is None: + file_obj = open(path, encoding=encoding) + else: + file_obj = os.fdopen( + os.dup(file_descriptor), + mode="r", + encoding=encoding, + ) + file_obj.seek(0) + with file_obj: for index, line in enumerate(file_obj): if index < start: continue @@ -232,6 +243,7 @@ async def read_local_text_range( encoding: str, offset: int | None, limit: int | None, + file_descriptor: int | None = None, ) -> str: return await to_thread( read_local_text_range_sync, @@ -239,6 +251,7 @@ async def read_local_text_range( encoding=encoding, offset=offset, limit=limit, + file_descriptor=file_descriptor, ) @@ -273,8 +286,18 @@ async def _exec_python_json( return payload -async def _probe_local_file(path: str) -> dict[str, str | int]: +async def _probe_local_file( + path: str, + file_descriptor: int | None = None, +) -> dict[str, str | int]: def _run() -> dict[str, str | int]: + if file_descriptor is not None: + return { + "size_bytes": os.fstat(file_descriptor).st_size, + "sample_b64": base64.b64encode( + os.pread(file_descriptor, _FILE_SNIFF_BYTES, 0) + ).decode("utf-8"), + } file_path = Path(path) with file_path.open("rb") as file_obj: sample = file_obj.read(_FILE_SNIFF_BYTES) @@ -286,9 +309,17 @@ def _run() -> dict[str, str | int]: return await to_thread(_run) -async def _read_local_image_base64(path: str) -> dict[str, str | int]: +async def _read_local_image_base64( + path: str, + file_descriptor: int | None = None, +) -> dict[str, str | int]: def _run() -> dict[str, str | int]: - data = Path(path).read_bytes() + if file_descriptor is None: + data = Path(path).read_bytes() + else: + with os.fdopen(os.dup(file_descriptor), "rb") as file_obj: + file_obj.seek(0) + data = file_obj.read() return { "size_bytes": len(data), "base64": base64.b64encode(data).decode("utf-8"), @@ -297,8 +328,19 @@ def _run() -> dict[str, str | int]: return await to_thread(_run) -async def _read_local_file_bytes(path: str) -> bytes: - return await to_thread(Path(path).read_bytes) +async def _read_local_file_bytes( + path: str, + file_descriptor: int | None = None, +) -> bytes: + if file_descriptor is None: + return await to_thread(Path(path).read_bytes) + + def _run() -> bytes: + with os.fdopen(os.dup(file_descriptor), "rb") as file_obj: + file_obj.seek(0) + return file_obj.read() + + return await to_thread(_run) async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]: @@ -411,30 +453,31 @@ async def _parse_local_epub_text(file_bytes: bytes, file_name: str) -> str: async def _parse_local_supported_document( path: str, sample: bytes, + file_descriptor: int | None = None, ) -> ParsedDocument | None: file_name = Path(path).name suffix = Path(path).suffix.lower() if _looks_like_pdf(path, sample): - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) text = await _parse_local_pdf_text(file_bytes, file_name) return ParsedDocument(kind="pdf", file_bytes=file_bytes, text=text) if suffix == ".epub": - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) if not _is_epub_bytes(file_bytes): return None text = await _parse_local_epub_text(file_bytes, file_name) return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text) if suffix == ".docx": - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) if not _is_docx_bytes(file_bytes): return None text = await _parse_local_docx_text(file_bytes, file_name) return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text) if _looks_like_zip_container(sample): - file_bytes = await _read_local_file_bytes(path) + file_bytes = await _read_local_file_bytes(path, file_descriptor) if _is_epub_bytes(file_bytes): text = await _parse_local_epub_text(file_bytes, file_name) return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text) @@ -536,6 +579,7 @@ async def _store_converted_text_for_workspace( original_path: str, original_bytes: bytes, content: str, + restricted: bool, ) -> str: def _run() -> str: original_name = Path(original_path).name @@ -543,9 +587,28 @@ def _run() -> str: target_dir = ( Path(workspace_dir) / "converted_files" / f"{original_name}_{digest_suffix}" ) - target_dir.mkdir(parents=True, exist_ok=True) target_path = target_dir / "text.txt" - target_path.write_text(content, encoding="utf-8") + if restricted: + target_fd = open_file_in_allowed_roots( + str(target_path), + (Path(workspace_dir),), + access="write", + create_parents=True, + ) + try: + os.ftruncate(target_fd, 0) + os.lseek(target_fd, 0, os.SEEK_SET) + with os.fdopen( + os.dup(target_fd), + mode="w", + encoding="utf-8", + ) as file_obj: + file_obj.write(content) + finally: + os.close(target_fd) + else: + target_dir.mkdir(parents=True, exist_ok=True) + target_path.write_text(content, encoding="utf-8") return str(target_path) return await to_thread(_run) @@ -585,6 +648,7 @@ async def _read_local_supported_document_result( workspace_dir: str | None, offset: int | None, limit: int | None, + restricted: bool, ) -> ToolExecResult: content = parsed_document.text if not content: @@ -609,6 +673,7 @@ async def _read_local_supported_document_result( original_path=path, original_bytes=parsed_document.file_bytes, content=content, + restricted=restricted, ) if offset is None and limit is None: @@ -652,9 +717,10 @@ async def read_file_tool_result( offset: int | None, limit: int | None, workspace_dir: str | None = None, + local_file_descriptor: int | None = None, ) -> ToolExecResult: if local_mode: - probe_payload = await _probe_local_file(path) + probe_payload = await _probe_local_file(path, local_file_descriptor) else: probe_payload = await _exec_python_json( booter, @@ -668,7 +734,11 @@ async def read_file_tool_result( if local_mode: try: - parsed_document = await _parse_local_supported_document(path, sample) + parsed_document = await _parse_local_supported_document( + path, + sample, + local_file_descriptor, + ) except Exception as exc: return f"Error reading file: failed to parse document: {exc}" @@ -679,6 +749,7 @@ async def read_file_tool_result( workspace_dir=workspace_dir, offset=offset, limit=limit, + restricted=local_file_descriptor is not None, ) if probe.kind == "binary": @@ -686,7 +757,10 @@ async def read_file_tool_result( if probe.kind == "image": if local_mode: - image_payload = await _read_local_image_base64(path) + image_payload = await _read_local_image_base64( + path, + local_file_descriptor, + ) else: image_payload = await _exec_python_json( booter, @@ -723,6 +797,7 @@ async def read_file_tool_result( encoding=probe.encoding or "utf-8", offset=offset, limit=limit, + file_descriptor=local_file_descriptor, ) else: text_payload = await _exec_python_json( diff --git a/astrbot/core/computer/local_file_security.py b/astrbot/core/computer/local_file_security.py new file mode 100644 index 0000000000..ff59a282e5 --- /dev/null +++ b/astrbot/core/computer/local_file_security.py @@ -0,0 +1,187 @@ +"""Race-resistant file opening for restricted Local computer tools.""" + +from __future__ import annotations + +import errno +import os +import stat +from pathlib import Path +from typing import Literal + + +def open_file_in_allowed_roots( + path: str, + allowed_roots: tuple[Path, ...], + *, + access: Literal["read", "write", "edit"], + create_parents: bool = False, +) -> int: + """Open a regular file without following attacker-controlled path links. + + Args: + path: Absolute normalized file path selected by the caller. + allowed_roots: Trusted directories that may contain the file. + access: Whether the descriptor is used for reading, writing, or editing. + create_parents: Whether missing parent directories and the final file may + be created. + + Returns: + An open file descriptor owned by the caller. + + Raises: + FileNotFoundError: If a required path component does not exist. + IsADirectoryError: If the final path is a directory. + PermissionError: If the path leaves the allowed roots, contains a symbolic + link, is not a regular file, or aliases a multiply linked file. + RuntimeError: If descriptor-relative no-follow access is unavailable. + ValueError: If an unsupported access mode is requested. + """ + if ( + os.name == "nt" + or not hasattr(os, "O_DIRECTORY") + or not hasattr(os, "O_NOFOLLOW") + or not hasattr(os, "pread") + or os.open not in os.supports_dir_fd + or os.mkdir not in os.supports_dir_fd + ): + raise RuntimeError( + "Race-resistant restricted file access is unavailable on this platform." + ) + + candidate = Path(path) + if not candidate.is_absolute(): + raise PermissionError(f"Restricted file path must be absolute: {path}.") + + root_matches: list[tuple[Path, Path]] = [] + for root in allowed_roots: + try: + root_matches.append((root, candidate.relative_to(root))) + except ValueError: + continue + if not root_matches: + raise PermissionError( + f"Access denied: path is outside restricted roots: {path}." + ) + + root, relative_path = max(root_matches, key=lambda item: len(item[0].parts)) + parts = relative_path.parts + if not parts: + raise IsADirectoryError(path) + if any(part in {"", ".", ".."} for part in parts): + raise PermissionError(f"Access denied: unsafe restricted path: {path}.") + + directory_flags = ( + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) + ) + try: + directory_fd = os.open(root, directory_flags) + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise PermissionError( + f"Access denied: restricted root changed or is a symbolic link: {root}." + ) from exc + raise + + try: + for component in parts[:-1]: + try: + next_directory_fd = os.open( + component, + directory_flags, + dir_fd=directory_fd, + ) + except FileNotFoundError: + if not create_parents: + raise + try: + os.mkdir(component, mode=0o755, dir_fd=directory_fd) + except FileExistsError: + pass + try: + next_directory_fd = os.open( + component, + directory_flags, + dir_fd=directory_fd, + ) + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise PermissionError( + "Access denied: restricted path changed or contains a " + f"symbolic link: {path}." + ) from exc + raise + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise PermissionError( + "Access denied: restricted path changed or contains a " + f"symbolic link: {path}." + ) from exc + raise + os.close(directory_fd) + directory_fd = next_directory_fd + + if access == "read": + file_flags = os.O_RDONLY + elif access == "write": + file_flags = os.O_WRONLY + elif access == "edit": + file_flags = os.O_RDWR + else: + raise ValueError(f"Unsupported restricted file access mode: {access}.") + file_flags |= ( + os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0) + ) + + final_name = parts[-1] + try: + file_fd = os.open(final_name, file_flags, dir_fd=directory_fd) + except FileNotFoundError: + if not create_parents: + raise + try: + file_fd = os.open( + final_name, + file_flags | os.O_CREAT | os.O_EXCL, + 0o666, + dir_fd=directory_fd, + ) + except FileExistsError: + try: + file_fd = os.open(final_name, file_flags, dir_fd=directory_fd) + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise PermissionError( + "Access denied: restricted path changed or contains a " + f"symbolic link: {path}." + ) from exc + raise + except OSError as exc: + if exc.errno in {errno.ELOOP, errno.ENOTDIR}: + raise PermissionError( + "Access denied: restricted path changed or contains a " + f"symbolic link: {path}." + ) from exc + raise + + try: + file_stat = os.fstat(file_fd) + except OSError: + os.close(file_fd) + raise + if not stat.S_ISREG(file_stat.st_mode): + os.close(file_fd) + if stat.S_ISDIR(file_stat.st_mode): + raise IsADirectoryError(path) + raise PermissionError( + f"Access denied: restricted path is not a regular file: {path}." + ) + if file_stat.st_nlink > 1: + os.close(file_fd) + raise PermissionError( + "Access denied: file has multiple hard links and may alias content " + f"outside allowed directories. Link count: {file_stat.st_nlink}. " + f"Blocked path: {path}." + ) + return file_fd + finally: + os.close(directory_fd) diff --git a/astrbot/core/computer/process_sandbox/__init__.py b/astrbot/core/computer/process_sandbox/__init__.py new file mode 100644 index 0000000000..057b2ad9f3 --- /dev/null +++ b/astrbot/core/computer/process_sandbox/__init__.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import sys + +from .base import ( + ProcessSandbox, + SandboxLimits, + SandboxProcess, + SandboxRunResult, + SandboxSpec, + SandboxTimeoutError, +) + + +def create_process_sandbox() -> ProcessSandbox: + """Select the restricted-process launcher for the current system. + + Returns: + Bubblewrap on Linux or Seatbelt on macOS. + + Raises: + RuntimeError: If the current system has no Local sandbox implementation. + """ + if sys.platform.startswith("linux"): + from .bubblewrap import BubblewrapProcessSandbox + + return BubblewrapProcessSandbox() + if sys.platform == "darwin": + from .seatbelt import SeatbeltProcessSandbox + + return SeatbeltProcessSandbox() + raise RuntimeError("No Local process sandbox backend is available.") + + +__all__ = ( + "ProcessSandbox", + "SandboxLimits", + "SandboxProcess", + "SandboxRunResult", + "SandboxSpec", + "SandboxTimeoutError", + "create_process_sandbox", +) diff --git a/astrbot/core/computer/process_sandbox/base.py b/astrbot/core/computer/process_sandbox/base.py new file mode 100644 index 0000000000..0b12259465 --- /dev/null +++ b/astrbot/core/computer/process_sandbox/base.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class SandboxLimits: + """Resource ceilings applied to a sandboxed process tree. + + Args: + cpu_seconds: Maximum CPU time in seconds. + file_size_bytes: Maximum size of a file created by one process. + memory_bytes: Maximum address space or job memory in bytes. + open_files: Maximum number of open file descriptors or handles when + supported by the platform. + processes: Maximum number of processes in the sandbox. + """ + + cpu_seconds: int = 300 + file_size_bytes: int = 100 * 1024 * 1024 + memory_bytes: int = 1024 * 1024 * 1024 + open_files: int = 256 + processes: int = 256 + + def __post_init__(self) -> None: + """Validate that every resource ceiling is a positive integer. + + Raises: + ValueError: If a resource ceiling is not a positive integer. + """ + for name in ( + "cpu_seconds", + "file_size_bytes", + "memory_bytes", + "open_files", + "processes", + ): + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"Sandbox limit `{name}` must be a positive integer.") + + +@dataclass(frozen=True, slots=True) +class SandboxSpec: + """Permissions, workspace, and limits for a sandboxed process. + + Args: + workspace: Directory exposed as the process working directory. + workspace_writable: Whether the process may modify the workspace. + allow_network: Whether the process may access the network. + filesystem_scope: Whether the process sees only its workspace or the + host filesystem. + limits: Resource ceilings enforced by the platform backend. + readable_roots: Additional directories that may be read in workspace scope. + writable_roots: Additional directories that may be read and modified in + workspace scope. Missing writable directories are created before launch. + """ + + workspace: Path + workspace_writable: bool = True + allow_network: bool = False + filesystem_scope: str = "workspace" + limits: SandboxLimits = field(default_factory=SandboxLimits) + readable_roots: tuple[Path, ...] = () + writable_roots: tuple[Path, ...] = () + + +@dataclass(frozen=True, slots=True) +class SandboxRunResult: + """Result returned by a synchronous sandbox execution. + + Args: + returncode: Process exit status. + stdout: Captured standard output. + stderr: Captured standard error. + stdout_limited: Whether standard output exceeded the requested limit. + stderr_limited: Whether standard error exceeded the requested limit. + """ + + returncode: int + stdout: bytes = b"" + stderr: bytes = b"" + stdout_limited: bool = False + stderr_limited: bool = False + + +class SandboxTimeoutError(TimeoutError): + """Raised when a sandbox process exceeds its execution timeout.""" + + +class SandboxStdin(Protocol): + """Writable stream used by managed sandbox processes.""" + + def write(self, data: bytes) -> None: + """Buffer bytes for the process standard input.""" + ... + + async def drain(self) -> None: + """Flush buffered bytes without blocking the event loop.""" + ... + + +class SandboxStdout(Protocol): + """Readable stream used by managed sandbox processes.""" + + async def read(self, n: int = -1) -> bytes: + """Read up to ``n`` bytes from the process standard output.""" + ... + + +class SandboxProcess(Protocol): + """Process operations used by managed Local shell sessions.""" + + @property + def pid(self) -> int: + """Return the process identifier.""" + ... + + @property + def returncode(self) -> int | None: + """Return the exit status, or ``None`` while the process is running.""" + ... + + @property + def stdin(self) -> SandboxStdin | None: + """Return the process standard-input stream when configured.""" + ... + + @property + def stdout(self) -> SandboxStdout | None: + """Return the process standard-output stream when configured.""" + ... + + async def wait(self) -> int: + """Wait for the process to exit.""" + ... + + def interrupt(self) -> None: + """Interrupt the sandbox process tree.""" + ... + + def terminate(self) -> None: + """Request graceful termination of the sandbox process tree.""" + ... + + def kill(self) -> None: + """Force termination of the sandbox process tree.""" + ... + + +class ProcessSandbox(ABC): + """Platform-independent launcher for restricted child processes.""" + + def _prepare_command( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> tuple[list[str], Path, dict[str, str]]: + """Validate and normalize a command before platform-specific launch. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem and network access granted to the process. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Normalized arguments, workspace, and environment values. + + Raises: + RuntimeError: If the workspace does not exist. + ValueError: If the command, scope, or environment is invalid. + """ + if not argv: + raise ValueError("A sandbox command is required.") + if spec.filesystem_scope not in {"workspace", "host"}: + raise ValueError( + f"Invalid Local filesystem scope: {spec.filesystem_scope}." + ) + + sandbox_argv = list(argv) + workspace = spec.workspace.resolve() + if not workspace.is_dir(): + raise RuntimeError(f"Sandbox workspace does not exist: {workspace}") + if spec.filesystem_scope == "workspace": + for root in spec.writable_roots: + root.mkdir(parents=True, exist_ok=True) + + normalized_env: dict[str, str] = {} + for raw_key, raw_value in (env or {}).items(): + key = str(raw_key) + value = str(raw_value) + if not key or "=" in key or "\x00" in key or "\x00" in value: + raise ValueError(f"Invalid sandbox environment variable name: {key!r}.") + normalized_env[key] = value + + return sandbox_argv, workspace, normalized_env + + @abstractmethod + def run( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + timeout: float | None = None, + output_limit: int | None = None, + discard_stdout: bool = False, + ) -> SandboxRunResult: + """Run a restricted process synchronously. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem and network access granted to the process. + env: Additional environment variables exposed inside the sandbox. + timeout: Maximum wall-clock runtime in seconds. + output_limit: Maximum captured bytes for each output stream. + discard_stdout: Whether to discard standard output. + + Returns: + Platform-independent process result. + + Raises: + SandboxTimeoutError: If the process exceeds ``timeout``. + """ + raise NotImplementedError + + @abstractmethod + async def spawn_shell( + self, + command: str, + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> SandboxProcess: + """Start a managed shell command asynchronously. + + Args: + command: Shell command to execute inside the sandbox. + spec: Filesystem and network access granted to the process. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Running restricted process. + """ + raise NotImplementedError diff --git a/astrbot/core/computer/process_sandbox/bubblewrap.py b/astrbot/core/computer/process_sandbox/bubblewrap.py new file mode 100644 index 0000000000..55f037e11c --- /dev/null +++ b/astrbot/core/computer/process_sandbox/bubblewrap.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from .base import SandboxSpec +from .unix import UnixProcessSandbox, build_resource_limited_argv + +_TMP_BYTES = 256 * 1024 * 1024 +_NETWORK_CONFIG_PATHS = { + Path("/etc/resolv.conf"), + Path("/etc/hosts"), + Path("/etc/host.conf"), + Path("/etc/gai.conf"), +} + + +class BubblewrapProcessSandbox(UnixProcessSandbox): + """Linux restricted-process launcher backed by bubblewrap.""" + + def _build_command( + self, + argv: list[str], + workspace: Path, + spec: SandboxSpec, + env: dict[str, str], + ) -> list[str]: + """Build the bubblewrap command for validated inputs.""" + bwrap_path = shutil.which("bwrap") + if not bwrap_path: + raise RuntimeError( + "bubblewrap (`bwrap`) is required for restricted Local execution." + ) + if not Path("/bin/sh").exists(): + raise RuntimeError("The Local bubblewrap sandbox requires /bin/sh.") + + executable_path = ( + Path(argv[0]).resolve() + if Path(argv[0]).is_absolute() and Path(argv[0]).exists() + else Path(sys.executable).resolve() + ) + command = [ + bwrap_path, + "--unshare-all", + "--new-session", + "--die-with-parent", + "--clearenv", + ] + if spec.allow_network: + command.append("--share-net") + + if spec.filesystem_scope == "host": + command.extend( + ( + "--bind", + "/", + "/", + "--proc", + "/proc", + "--dev", + "/dev", + "--chdir", + str(workspace), + ) + ) + else: + command.extend( + ("--dir", "/tmp", "--size", str(_TMP_BYTES), "--tmpfs", "/tmp") + ) + + readonly_paths = { + Path("/usr"), + Path("/bin"), + Path("/sbin"), + Path("/lib"), + Path("/lib64"), + Path("/etc/alternatives"), + Path("/etc/ld.so.cache"), + Path("/etc/ld.so.conf"), + Path("/etc/ld.so.conf.d"), + Path("/etc/localtime"), + Path("/etc/nsswitch.conf"), + Path("/etc/passwd"), + Path("/etc/group"), + Path(sys.prefix).resolve(), + Path(sys.base_prefix).resolve(), + } + if spec.filesystem_scope == "workspace": + if spec.allow_network: + readonly_paths.update(_NETWORK_CONFIG_PATHS) + readonly_paths.update(root.resolve() for root in spec.readable_roots) + writable_paths = {root.resolve() for root in spec.writable_roots} + if spec.workspace_writable: + writable_paths.add(workspace) + else: + readonly_paths.add(workspace) + readonly_paths.difference_update(writable_paths) + if not any( + executable_path == path or executable_path.is_relative_to(path) + for path in readonly_paths + ): + readonly_paths.add(executable_path) + # Keep the venv entry point usable when uv links its interpreter + # through a directory alias outside the mounted Python prefixes. + pending = [Path(sys.executable)] + seen_links: set[Path] = set() + while pending: + path = pending.pop() + for link in (path, *path.parents): + if link in seen_links or not link.is_symlink(): + continue + seen_links.add(link) + target = link.parent / link.readlink() / path.relative_to(link) + pending.append(Path(os.path.abspath(target))) + if not any(link.is_relative_to(root) for root in readonly_paths): + readonly_paths.add(link) + readonly_paths = {path for path in readonly_paths if path.exists()} + + required_directories = {Path("/tmp"), Path("/tmp/home")} + for path in (*readonly_paths, *writable_paths): + required_directories.update( + parent + for parent in path.parents + if parent != Path("/") and parent not in readonly_paths + ) + for directory in sorted( + required_directories, + key=lambda path: len(path.parts), + ): + if directory != Path("/tmp"): + command.extend(("--dir", str(directory))) + command.extend(("--proc", "/proc", "--dev", "/dev")) + + for path in sorted(readonly_paths, key=lambda item: len(item.parts)): + # Resolver files often link into /run. Bind their contents without + # exposing the rest of the host service's runtime directory. + if path.is_symlink() and path not in _NETWORK_CONFIG_PATHS: + command.extend(("--symlink", os.readlink(path), str(path))) + else: + command.extend(("--ro-bind", str(path), str(path))) + for path in sorted(writable_paths, key=lambda item: len(item.parts)): + command.extend(("--bind", str(path), str(path))) + # A writable workspace or attachment root must not make AstrBot's + # Python installation writable when it contains that installation. + for path in sorted( + {Path(sys.prefix).resolve(), Path(sys.base_prefix).resolve()}, + key=lambda item: len(item.parts), + ): + if any( + path.is_relative_to(root) or root.is_relative_to(path) + for root in writable_paths + ): + command.extend(("--ro-bind", str(path), str(path))) + command.extend(("--chdir", str(workspace))) + + for key, value in sorted(env.items()): + command.extend(("--setenv", key, value)) + command.extend( + ( + "--setenv", + "PATH", + f"{Path(sys.executable).parent}:/usr/local/bin:/usr/bin:/bin", + "--setenv", + "HOME", + str(workspace) if spec.filesystem_scope == "host" else "/tmp/home", + "--setenv", + "TMPDIR", + "/tmp", + "--setenv", + "LANG", + "C.UTF-8", + "--", + *build_resource_limited_argv(argv, spec.limits), + ) + ) + return command diff --git a/astrbot/core/computer/process_sandbox/seatbelt.py b/astrbot/core/computer/process_sandbox/seatbelt.py new file mode 100644 index 0000000000..9527a8c266 --- /dev/null +++ b/astrbot/core/computer/process_sandbox/seatbelt.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from .base import SandboxSpec +from .unix import UnixProcessSandbox, build_resource_limited_argv + +_PROFILE = """ +(version 1) +(deny default) +(deny mach-priv-host-port) +(import "system.sb") + +(allow process-fork) +(allow process-exec) +(allow process-info* (target self)) +(deny process-exec + (literal "/usr/bin/open") + (literal "/usr/bin/osascript")) +(deny appleevent-send) +(deny mach-lookup + (global-name "com.apple.coreservices.launchservicesd") + (global-name "com.apple.lsd.mapdb") + (global-name "com.apple.lsd.modifydb") + (global-name "com.apple.lsd.open") + (global-name "com.apple.lsd.xpc")) + +(allow file-read-metadata file-test-existence) +(allow file-read* file-test-existence + (subpath "/bin") + (subpath "/usr/bin") + (subpath "/usr/libexec") + (literal (param "EXECUTABLE")) + (subpath (param "WORKSPACE")) + (subpath (param "PYTHON_PREFIX")) + (subpath (param "PYTHON_BASE_PREFIX"))) +(allow file-map-executable + (subpath "/bin") + (subpath "/usr/bin") + (subpath "/usr/libexec") + (literal (param "EXECUTABLE")) + (subpath (param "WORKSPACE")) + (subpath (param "PYTHON_PREFIX")) + (subpath (param "PYTHON_BASE_PREFIX"))) +(allow file-write* + (subpath (param "WORKSPACE"))) + +(deny file-read* + (literal "/private/etc/master.passwd") + (literal "/private/etc/passwd")) +(deny network*) +""" +_READ_ONLY_PROFILE = _PROFILE.replace( + '(allow file-write*\n (subpath (param "WORKSPACE")))', + "(deny file-write*)", +) + + +class SeatbeltProcessSandbox(UnixProcessSandbox): + """macOS restricted-process launcher backed by Seatbelt.""" + + def _build_command( + self, + argv: list[str], + workspace: Path, + spec: SandboxSpec, + env: dict[str, str], + ) -> list[str]: + """Build the Seatbelt command for validated inputs.""" + seatbelt_path = shutil.which("sandbox-exec", path="/usr/bin") + if seatbelt_path != "/usr/bin/sandbox-exec": + raise RuntimeError( + "Seatbelt (`/usr/bin/sandbox-exec`) is required for restricted " + "Local execution on macOS." + ) + + executable_path = ( + Path(argv[0]).resolve() + if Path(argv[0]).is_absolute() and Path(argv[0]).exists() + else Path(sys.executable).resolve() + ) + profile = _PROFILE if spec.workspace_writable else _READ_ONLY_PROFILE + if spec.filesystem_scope == "host": + profile = profile.replace( + '(import "system.sb")', + '(import "system.sb")\n\n' + "(allow file-read* file-write* file-test-existence " + "file-read-metadata file-map-executable)", + ).replace( + "(deny file-read*\n" + ' (literal "/private/etc/master.passwd")\n' + ' (literal "/private/etc/passwd"))\n', + "", + ) + if spec.allow_network: + profile = profile.replace("(deny network*)", "(allow network*)") + + root_definitions: list[str] = [] + if spec.filesystem_scope == "workspace": + writable_roots = {root.resolve() for root in spec.writable_roots} + readable_roots = { + root.resolve() + for root in (*spec.readable_roots, *spec.writable_roots) + if root.is_dir() + } + for index, root in enumerate(sorted(readable_roots)): + parameter = f"ALLOWED_ROOT_{index}" + root_definitions.extend(("-D", f"{parameter}={root}")) + operations = "file-read* file-map-executable" + if root in writable_roots: + operations += " file-write*" + profile += f'\n(allow {operations} (subpath (param "{parameter}")))\n' + # Explicit denial also protects Python inside an otherwise writable root. + profile += ( + '\n(deny file-write* (subpath (param "PYTHON_PREFIX")) ' + '(subpath (param "PYTHON_BASE_PREFIX")))\n' + ) + + executable_definitions: list[str] = [] + executable_rules: list[str] = [] + for index, read_path in enumerate(self._executable_read_paths(executable_path)): + parameter = f"EXECUTABLE_{index}" + executable_definitions.extend(("-D", f"{parameter}={read_path}")) + executable_rules.append(f'(literal (param "{parameter}"))') + profile = profile.replace( + '(literal (param "EXECUTABLE"))', + "\n ".join(executable_rules), + ) + + environment = [ + *(f"{key}={value}" for key, value in sorted(env.items())), + f"PATH={Path(sys.executable).parent}:/usr/bin:/bin", + f"HOME={workspace}", + f"TMPDIR={workspace}", + "LANG=C.UTF-8", + ] + return [ + seatbelt_path, + "-D", + f"WORKSPACE={workspace}", + *root_definitions, + *executable_definitions, + "-D", + f"PYTHON_PREFIX={Path(sys.prefix).resolve()}", + "-D", + f"PYTHON_BASE_PREFIX={Path(sys.base_prefix).resolve()}", + "-p", + profile, + "/usr/bin/env", + "-i", + *environment, + *build_resource_limited_argv(argv, spec.limits), + ] + + def _executable_read_paths(self, executable_path: Path) -> tuple[Path, ...]: + """Collect executable and dynamic-library paths needed by Seatbelt. + + Args: + executable_path: Executable launched inside Seatbelt. + + Returns: + Existing absolute files that the dynamic loader may need to read. + """ + read_paths = {executable_path, executable_path.resolve()} + resolved_executable = executable_path.resolve() + if any( + resolved_executable.is_relative_to(root) + for root in ( + Path("/bin"), + Path("/usr"), + Path(sys.prefix).resolve(), + Path(sys.base_prefix).resolve(), + ) + ): + return tuple(sorted(read_paths, key=str)) + + pending = [resolved_executable] + inspected: set[Path] = set() + while pending and len(inspected) < 64: + current = pending.pop() + if current in inspected: + continue + inspected.add(current) + try: + result = subprocess.run( + ["/usr/bin/otool", "-L", str(current)], + capture_output=True, + check=False, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if result.returncode != 0: + continue + for line in result.stdout.decode("utf-8", errors="replace").splitlines()[ + 1: + ]: + dependency_text = line.strip().split(" (", 1)[0] + if not dependency_text.startswith("/"): + continue + dependency = Path(dependency_text) + if not dependency.exists(): + continue + resolved_dependency = dependency.resolve() + read_paths.update((dependency, resolved_dependency)) + if not ( + resolved_dependency.is_relative_to("/usr") + or resolved_dependency.is_relative_to("/System") + ): + pending.append(resolved_dependency) + return tuple(sorted(read_paths, key=str)) diff --git a/astrbot/core/computer/process_sandbox/unix.py b/astrbot/core/computer/process_sandbox/unix.py new file mode 100644 index 0000000000..e8c01f1b11 --- /dev/null +++ b/astrbot/core/computer/process_sandbox/unix.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import asyncio +import os +import signal as signal_module +import subprocess +import sys +import tempfile +from abc import abstractmethod +from pathlib import Path + +from .base import ( + ProcessSandbox, + SandboxLimits, + SandboxProcess, + SandboxRunResult, + SandboxSpec, + SandboxStdin, + SandboxStdout, + SandboxTimeoutError, +) + + +class UnixSandboxProcess: + """Adapt an asyncio process to process-tree sandbox semantics.""" + + def __init__(self, process: asyncio.subprocess.Process) -> None: + """Store the session-leading asyncio process. + + Args: + process: Process started in a new Unix session. + """ + self._process = process + + @property + def pid(self) -> int: + """Return the process-group leader identifier.""" + return self._process.pid + + @property + def returncode(self) -> int | None: + """Return the process exit status when available.""" + return self._process.returncode + + @property + def stdin(self) -> SandboxStdin | None: + """Return the native asyncio standard-input stream.""" + return self._process.stdin + + @property + def stdout(self) -> SandboxStdout | None: + """Return the native asyncio standard-output stream.""" + return self._process.stdout + + async def wait(self) -> int: + """Wait for the session-leading process to exit.""" + return await self._process.wait() + + def interrupt(self) -> None: + """Send SIGINT to the complete Unix process group.""" + self._send_signal(signal_module.SIGINT) + + def terminate(self) -> None: + """Send SIGTERM to the complete Unix process group.""" + self._send_signal(signal_module.SIGTERM) + + def kill(self) -> None: + """Send SIGKILL to the complete Unix process group.""" + self._send_signal(signal_module.SIGKILL) + + def _send_signal(self, signal: int) -> None: + """Send a Unix signal to the process group if it still exists. + + Args: + signal: Unix signal number to send. + """ + try: + os.killpg(self.pid, signal) + except ProcessLookupError: + pass + + +class UnixProcessSandbox(ProcessSandbox): + """Common launcher behavior for Unix sandbox implementations.""" + + def build_command( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> list[str]: + """Build a Unix sandbox wrapper command. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem, network, and resource policy. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Platform sandbox command and arguments. + """ + argv, workspace, env = self._prepare_command(argv, spec, env=env) + return self._build_command(argv, workspace, spec, env) + + def run( + self, + argv: list[str], + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + timeout: float | None = None, + output_limit: int | None = None, + discard_stdout: bool = False, + ) -> SandboxRunResult: + """Run a command through the Unix sandbox wrapper. + + Args: + argv: Command and arguments to execute inside the sandbox. + spec: Filesystem, network, and resource policy. + env: Additional environment variables exposed inside the sandbox. + timeout: Maximum wall-clock runtime in seconds. + output_limit: Maximum captured bytes for each output stream. + discard_stdout: Whether to discard standard output. + + Returns: + Captured process result. + + Raises: + SandboxTimeoutError: If the process exceeds ``timeout``. + ValueError: If ``output_limit`` is not positive. + """ + if output_limit is not None and output_limit <= 0: + raise ValueError("Sandbox output limit must be greater than 0.") + + with ( + tempfile.TemporaryFile() as stdout_file, + tempfile.TemporaryFile() as stderr_file, + ): + try: + result = subprocess.run( + self.build_command(argv, spec, env=env), + cwd=spec.workspace.resolve(), + env={"PATH": os.defpath}, + timeout=timeout, + stdout=subprocess.DEVNULL if discard_stdout else stdout_file, + stderr=stderr_file, + ) + except subprocess.TimeoutExpired as exc: + raise SandboxTimeoutError( + f"Sandbox command timed out after {timeout} seconds." + ) from exc + + read_size = None if output_limit is None else output_limit + 1 + if discard_stdout: + stdout = b"" + else: + stdout_file.seek(0) + stdout = stdout_file.read(read_size) + stderr_file.seek(0) + stderr = stderr_file.read(read_size) + return SandboxRunResult( + returncode=result.returncode, + stdout=stdout[:output_limit] if output_limit is not None else stdout, + stderr=stderr[:output_limit] if output_limit is not None else stderr, + stdout_limited=output_limit is not None and len(stdout) > output_limit, + stderr_limited=output_limit is not None and len(stderr) > output_limit, + ) + + async def spawn_shell( + self, + command: str, + spec: SandboxSpec, + *, + env: dict[str, str] | None = None, + ) -> SandboxProcess: + """Start a shell command in a separately managed process group. + + Args: + command: Shell command to execute inside the sandbox. + spec: Filesystem, network, and resource policy. + env: Additional environment variables exposed inside the sandbox. + + Returns: + Process adapter whose lifecycle methods affect the process group. + """ + process = await asyncio.create_subprocess_exec( + *self.build_command(["/bin/sh", "-c", command], spec, env=env), + cwd=spec.workspace.resolve(), + env={"PATH": os.defpath}, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + start_new_session=True, + ) + return UnixSandboxProcess(process) + + @abstractmethod + def _build_command( + self, + argv: list[str], + workspace: Path, + spec: SandboxSpec, + env: dict[str, str], + ) -> list[str]: + """Build a Unix sandbox command after common input validation.""" + + +def build_resource_limited_argv( + argv: list[str], + limits: SandboxLimits, +) -> list[str]: + """Wrap a command with Unix resource-limit setup. + + Args: + argv: Command and arguments to execute after applying limits. + limits: Resource ceilings requested by the common sandbox policy. + + Returns: + Python command that applies supported Unix limits and then executes + ``argv``. + """ + wrapper_code = f""" +import os +import resource +import sys + +limits = [ + (resource.RLIMIT_CPU, {limits.cpu_seconds}), + (resource.RLIMIT_FSIZE, {limits.file_size_bytes}), + (resource.RLIMIT_NOFILE, {limits.open_files}), + (resource.RLIMIT_CORE, 0), +] +if sys.platform.startswith("linux"): + # macOS RLIMIT_NPROC counts every process owned by the host user, and its + # Python process starts above this virtual-address limit. + limits.extend( + ( + (resource.RLIMIT_NPROC, {limits.processes}), + (resource.RLIMIT_AS, {limits.memory_bytes}), + ) + ) +for kind, requested in limits: + _, hard = resource.getrlimit(kind) + value = requested if hard == resource.RLIM_INFINITY else min(requested, hard) + resource.setrlimit(kind, (value, value)) +os.execvpe(sys.argv[1], sys.argv[1:], os.environ) +""" + return [ + str(Path(sys.executable).resolve()), + "-I", + "-S", + "-c", + wrapper_code, + *argv, + ] diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index 9f14b205e4..0f1b15efb4 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -89,6 +89,26 @@ def __init__( config_migrated = False if default_config is DEFAULT_CONFIG: config_migrated = migrate_config_on_load(conf, Path(config_path)) + provider_settings = conf.get("provider_settings") + default_provider_settings = default_config.get("provider_settings") + if ( + isinstance(provider_settings, dict) + and isinstance(default_provider_settings, dict) + and "computer_use_local_permissions" in default_provider_settings + and "computer_use_local_permissions" not in provider_settings + ): + # Preserve legacy POSIX access; Windows uses its supported defaults. + permissions = copy.deepcopy( + default_provider_settings["computer_use_local_permissions"] + ) + permissions["member"]["allow_execution"] = permissions["member"][ + "filesystem_scope" + ] != "none" and not provider_settings.get( + "computer_use_require_admin", True + ) + permissions["admin"]["filesystem_scope"] = "host" + provider_settings["computer_use_local_permissions"] = permissions + config_migrated = True # 检查配置完整性,并插入 has_new = self.check_config_integrity(default_config, conf, schema=schema) has_new |= config_migrated diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 9cc95a13ed..f8c46c26e4 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1,6 +1,7 @@ """如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。""" import os +import platform from astrbot import __version__ from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG @@ -8,6 +9,32 @@ from .agent_runner import get_agent_runner_config_default + +def get_local_permission_defaults(system: str | None = None) -> dict: + """Return fresh Local permission defaults for the operating system. + + Args: + system: Operating system name, or None to use the current system. + + Returns: + Per-role policies. Windows disables member access and gives admins + unrestricted access because workspace isolation is unavailable. + """ + windows = (system or platform.system()).lower() == "windows" + return { + "member": { + "allow_execution": False, + "allow_network": False, + "filesystem_scope": "none" if windows else "workspace", + }, + "admin": { + "allow_execution": True, + "allow_network": True, + "filesystem_scope": "host" if windows else "workspace", + }, + } + + VERSION = __version__ DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") @@ -152,6 +179,7 @@ "add_cron_tools": True, }, "computer_use_runtime": "none", + "computer_use_local_permissions": get_local_permission_defaults(), "computer_use_require_admin": True, "sandbox": { "booter": "shipyard_neo", @@ -3759,13 +3787,53 @@ "description": "Computer Use Runtime", "type": "string", "options": ["none", "local", "sandbox"], - "labels": ["无", "本地", "沙箱"], + "labels": [ + "不允许任何环境", + "本机环境", + "第三方沙箱环境", + ], "hint": "选择 Computer Use 运行环境。", }, + "provider_settings.computer_use_local_permissions": { + "description": "本地权限策略", + "type": "object", + "_special": "local_permission_matrix", + "full_width": True, + "items": { + "member": { + "type": "object", + "items": { + "allow_execution": {"type": "bool"}, + "allow_network": {"type": "bool"}, + "filesystem_scope": { + "type": "string", + "options": ["none", "workspace", "host"], + }, + }, + }, + "admin": { + "type": "object", + "items": { + "allow_execution": {"type": "bool"}, + "allow_network": {"type": "bool"}, + "filesystem_scope": { + "type": "string", + "options": ["none", "workspace", "host"], + }, + }, + }, + }, + "condition": { + "provider_settings.computer_use_runtime": "local", + }, + }, "provider_settings.computer_use_require_admin": { - "description": "需要 AstrBot 管理员权限", + "description": "沙箱能力需要 AstrBot 管理员权限", "type": "bool", - "hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。", + "hint": "开启后,需要 AstrBot 管理员权限才能调用远程沙箱能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + }, }, "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index ba460c27c8..906a2f0d8a 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -7,18 +7,18 @@ `astrbot_read_file_tool`, `astrbot_file_write_tool`, `astrbot_file_edit_tool`, and `astrbot_grep_tool`. -Behavior when `provider_settings.computer_use_require_admin=True`: -- Admin + local: read/write/edit/grep are not path-restricted by this module; - access depends on the local runtime implementation and host OS permissions. - Upload and download tools are defined here, but `LocalBooter` does not - implement them and the main agent does not expose them in local mode. -- Member + local: read/grep are restricted to `data/skills`, - plugin-provided `data/plugins/*/skills`, - built-in plugin `astrbot/builtin_stars/*/skills`, - the current session or project workspace, and `/tmp/.astrbot`; write/edit are - restricted to the current workspace and temporary directories. Globally - installed and plugin-provided Skills are read-only. Upload/download are denied - by `check_admin_permission` if invoked. +Local behavior follows each role's `filesystem_scope` permission: +- `none`: read/write/edit/grep are denied before accessing any local resources. +- `host`: read/write/edit/grep are not path-restricted by this module; access + depends on host OS permissions. +- `workspace`: read/grep are restricted to globally installed Skills, + plugin-provided Skills, built-in plugin Skills, the current session or project + workspace, and AstrBot temporary directories. Write/edit are restricted to the + current workspace and temporary directories. Administrators may also update + globally installed Skills; plugin-provided and built-in Skills remain read-only. +- Upload and download tools are not exposed in Local mode. + +Remote Sandbox behavior still follows `computer_use_require_admin`: - Admin + sandbox: read/write/edit/grep are not path-restricted by this module; sandbox filesystem boundaries are enforced by the sandbox runtime. Upload and @@ -26,9 +26,6 @@ - Member + sandbox: read/write/edit/grep are also not path-restricted by this module. Upload/download are denied by `check_admin_permission` if invoked. -When `computer_use_require_admin=False`, member behavior in this module matches -admin behavior. - Local path resolution rule: - In local runtime, relative paths are resolved under the primary workspace. - In sandbox runtime, relative paths are passed through unchanged. @@ -39,6 +36,7 @@ import uuid from dataclasses import dataclass, field from pathlib import Path +from typing import Any, cast from astrbot.api import FunctionTool, logger from astrbot.api.event import MessageChain @@ -47,6 +45,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.computer_client import get_booter from astrbot.core.computer.file_read_utils import read_file_tool_result +from astrbot.core.computer.local_file_security import open_file_in_allowed_roots from astrbot.core.message.components import File, Image from astrbot.core.utils.astrbot_path import ( get_astrbot_builtin_plugin_path, @@ -60,6 +59,8 @@ from . import util as computer_util from .util import ( check_admin_permission, + check_local_file_permission, + get_local_permission_policy, is_local_runtime, normalize_umo_for_workspace, workspace_root_for_context, @@ -83,15 +84,17 @@ def _remote_basename(path: str) -> str: def _restricted_env_path_labels( umo: str, *, - include_global_skills: bool, + include_installed_skills: bool, + include_plugin_skills: bool, current_workspace_root: Path | None = None, ) -> list[str]: - """Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment""" + """Return labels for directories allowed by a workspace-scoped Local policy.""" labels = [] - if include_global_skills: + if include_installed_skills: + labels.append("data/skills") + if include_plugin_skills: labels.extend( [ - "data/skills", "data/plugins/*/skills", "astrbot/builtin_stars/*/skills", ] @@ -137,7 +140,7 @@ def _read_allowed_roots( umo: str, current_workspace_root: Path | None = None, ) -> tuple[Path, ...]: - """Non-admin users can only read files within these directories (and their subdirectories)""" + """Return roots readable by a workspace-scoped Local policy.""" return ( Path(get_astrbot_skills_path()).resolve(strict=False), *_plugin_skill_roots(), @@ -150,9 +153,16 @@ def _read_allowed_roots( def _write_allowed_roots( umo: str, current_workspace_root: Path | None = None, + *, + include_installed_skills: bool = False, ) -> tuple[Path, ...]: - """Non-admin users can modify only workspace and temporary files.""" + """Return writable roots for a workspace-scoped Local policy.""" return ( + *( + (Path(get_astrbot_skills_path()).resolve(strict=False),) + if include_installed_skills + else () + ), current_workspace_root or _workspace_root(umo), Path(get_astrbot_system_tmp_path()).resolve(strict=False), Path(get_astrbot_temp_path()).resolve(strict=False), @@ -160,14 +170,17 @@ def _write_allowed_roots( def _is_restricted_env(context: ContextWrapper[AstrAgentContext]) -> bool: - if not is_local_runtime(context): - return False - cfg = context.context.context.get_config( - umo=context.context.event.unified_msg_origin + """Return whether Local file access must stay within approved roots. + + Args: + context: Tool call context. + + Returns: + True when the caller's Local filesystem scope is workspace-only. + """ + return is_local_runtime(context) and ( + get_local_permission_policy(context).filesystem_scope == "workspace" ) - provider_settings = cfg.get("provider_settings", {}) - require_admin = provider_settings.get("computer_use_require_admin", True) - return require_admin and context.context.event.role != "admin" def _resolve_tool_path( @@ -254,6 +267,7 @@ def _normalize_rw_path( local_env: bool, umo: str, write: bool = False, + allow_installed_skill_write: bool = False, current_workspace_root: Path | None = None, ) -> str: normalized_path = _resolve_tool_path( @@ -266,7 +280,11 @@ def _normalize_rw_path( raise ValueError("`path` must be a non-empty string.") if restricted: allowed_roots = ( - _write_allowed_roots(umo, current_workspace_root) + _write_allowed_roots( + umo, + current_workspace_root, + include_installed_skills=allow_installed_skill_write, + ) if write else _read_allowed_roots(umo, current_workspace_root) ) @@ -279,7 +297,8 @@ def _normalize_rw_path( allowed = ", ".join( _restricted_env_path_labels( umo, - include_global_skills=not write, + include_installed_skills=not write or allow_installed_skill_write, + include_plugin_skills=not write, current_workspace_root=current_workspace_root, ) ) @@ -349,6 +368,9 @@ async def call( offset: int | None = None, limit: int | None = None, ) -> ToolExecResult: + permission_error = check_local_file_permission(context) + if permission_error: + return permission_error local_env = is_local_runtime(context) restricted = _is_restricted_env(context) current_workspace_root = ( @@ -378,20 +400,41 @@ async def call( context.context.context, context.context.event.unified_msg_origin, ) - return await read_file_tool_result( - sb, - local_mode=local_env, - path=normalized_path, - offset=offset, - limit=limit, - workspace_dir=( - str( - current_workspace_root - or _workspace_root(context.context.event.unified_msg_origin) - ) - if local_env - else None - ), + file_descriptor = None + if restricted: + file_descriptor = open_file_in_allowed_roots( + normalized_path, + _read_allowed_roots( + context.context.event.unified_msg_origin, + current_workspace_root, + ), + access="read", + ) + try: + return await read_file_tool_result( + sb, + local_mode=local_env, + path=normalized_path, + offset=offset, + limit=limit, + workspace_dir=( + str( + current_workspace_root + or _workspace_root(context.context.event.unified_msg_origin) + ) + if local_env + else None + ), + local_file_descriptor=file_descriptor, + ) + finally: + if file_descriptor is not None: + os.close(file_descriptor) + except IsADirectoryError: + return ( + f"Error: '{normalized_path}' is a directory, not a file. " + "Use a file path instead, or use 'astrbot_execute_shell' to list " + "directory contents." ) except PermissionError as exc: return f"Error: {exc}" @@ -428,6 +471,9 @@ async def call( path: str, content: str, ) -> ToolExecResult: + permission_error = check_local_file_permission(context) + if permission_error: + return permission_error local_env = is_local_runtime(context) restricted = _is_restricted_env(context) current_workspace_root = ( @@ -441,6 +487,7 @@ async def call( local_env=local_env, umo=context.context.event.unified_msg_origin, write=True, + allow_installed_skill_write=(context.context.event.role == "admin"), current_workspace_root=current_workspace_root, ) if local_env @@ -452,12 +499,41 @@ async def call( context.context.context, context.context.event.unified_msg_origin, ) - result = await sb.fs.write_file( - path=normalized_path, - content=content, - mode="w", - encoding="utf-8", - ) + file_descriptor = None + if restricted: + if current_workspace_root is not None: + current_workspace_root.mkdir(parents=True, exist_ok=True) + file_descriptor = open_file_in_allowed_roots( + normalized_path, + _write_allowed_roots( + context.context.event.unified_msg_origin, + current_workspace_root, + include_installed_skills=( + context.context.event.role == "admin" + ), + ), + access="write", + create_parents=True, + ) + try: + if file_descriptor is None: + result = await sb.fs.write_file( + path=normalized_path, + content=content, + mode="w", + encoding="utf-8", + ) + else: + result = await cast(Any, sb.fs).write_file( + path=normalized_path, + content=content, + mode="w", + encoding="utf-8", + file_descriptor=file_descriptor, + ) + finally: + if file_descriptor is not None: + os.close(file_descriptor) if not result.get("success", False): error_detail = str(result.get("error", "") or "").strip() return ( @@ -511,6 +587,9 @@ async def call( replace_all: bool = False, ) -> ToolExecResult: umo = str(context.context.event.unified_msg_origin) + permission_error = check_local_file_permission(context) + if permission_error: + return permission_error local_env = is_local_runtime(context) restricted = _is_restricted_env(context) current_workspace_root = ( @@ -524,6 +603,7 @@ async def call( local_env=local_env, umo=umo, write=True, + allow_installed_skill_write=(context.context.event.role == "admin"), current_workspace_root=current_workspace_root, ) if local_env @@ -537,13 +617,40 @@ async def call( context.context.context, context.context.event.unified_msg_origin, ) - result = await sb.fs.edit_file( - path=normalized_path, - old_string=normalized_old, - new_string=normalized_new, - replace_all=replace_all, - encoding="utf-8", - ) + file_descriptor = None + if restricted: + file_descriptor = open_file_in_allowed_roots( + normalized_path, + _write_allowed_roots( + umo, + current_workspace_root, + include_installed_skills=( + context.context.event.role == "admin" + ), + ), + access="edit", + ) + try: + if file_descriptor is None: + result = await sb.fs.edit_file( + path=normalized_path, + old_string=normalized_old, + new_string=normalized_new, + replace_all=replace_all, + encoding="utf-8", + ) + else: + result = await cast(Any, sb.fs).edit_file( + path=normalized_path, + old_string=normalized_old, + new_string=normalized_new, + replace_all=replace_all, + encoding="utf-8", + file_descriptor=file_descriptor, + ) + finally: + if file_descriptor is not None: + os.close(file_descriptor) if not result.get("success", False): error_detail = str(result.get("error", "") or "").strip() return ( @@ -693,6 +800,7 @@ def _normalize_search_paths( return [ str(root) for root in _read_allowed_roots(umo, current_workspace_root) + if root.exists() ] if local_env: return [str(current_workspace_root or _workspace_root(umo))] @@ -713,7 +821,8 @@ def _normalize_search_paths( allowed = ", ".join( _restricted_env_path_labels( umo, - include_global_skills=True, + include_installed_skills=True, + include_plugin_skills=True, current_workspace_root=current_workspace_root, ) ) @@ -740,6 +849,9 @@ async def call( if not normalized_pattern: return "Error: `pattern` must be a non-empty string." + permission_error = check_local_file_permission(context) + if permission_error: + return permission_error local_env = is_local_runtime(context) restricted = _is_restricted_env(context) current_workspace_root = ( @@ -769,13 +881,42 @@ async def call( ) contents: list[str] = [] for search_path in search_paths: - result = await sb.fs.search_files( - pattern=normalized_pattern, - path=search_path, - glob=glob, - after_context=after_context, - before_context=before_context, - ) + sandboxed = restricted + if sandboxed: + path_object = Path(search_path) + matching_roots = [ + root + for root in _read_allowed_roots( + context.context.event.unified_msg_origin, + current_workspace_root, + ) + if path_object == root or path_object.is_relative_to(root) + ] + if not matching_roots: + raise PermissionError( + "Access denied: search path is outside restricted roots. " + f"Blocked path: {search_path}." + ) + sandbox_root = str( + max(matching_roots, key=lambda root: len(root.parts)) + ) + result = await cast(Any, sb.fs).search_files( + pattern=normalized_pattern, + path=search_path, + glob=glob, + after_context=after_context, + before_context=before_context, + sandboxed=True, + sandbox_root=sandbox_root, + ) + else: + result = await sb.fs.search_files( + pattern=normalized_pattern, + path=search_path, + glob=glob, + after_context=after_context, + before_context=before_context, + ) if not result.get("success", False): error_detail = str(result.get("error", "") or "").strip() logger.error("GrepTool search failed: %s", error_detail) diff --git a/astrbot/core/tools/computer_tools/python.py b/astrbot/core/tools/computer_tools/python.py index 395ab653f7..0f8a759dc5 100644 --- a/astrbot/core/tools/computer_tools/python.py +++ b/astrbot/core/tools/computer_tools/python.py @@ -7,13 +7,15 @@ from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent +from astrbot.core.computer.booters.local import LocalPythonComponent from astrbot.core.computer.computer_client import get_booter, get_local_booter from astrbot.core.message.message_event_result import MessageChain from ..registry import builtin_tool +from .fs import _read_allowed_roots, _write_allowed_roots from .util import ( check_admin_permission, - is_local_runtime, + check_local_execution_permission, workspace_root_for_context, ) @@ -120,7 +122,8 @@ class LocalPythonTool(FunctionTool): name: str = "astrbot_execute_python" description: str = ( f"Execute codes in a Python environment. Current OS: {_OS_NAME}. " - "Use system-compatible commands." + "Use system-compatible commands. Restricted Linux and macOS calls run " + "inside an operating-system sandbox." ) parameters: dict = field(default_factory=lambda: param_schema) @@ -132,24 +135,48 @@ async def call( silent: bool = False, timeout: int = 30, ) -> ToolExecResult: - if permission_error := check_admin_permission(context, "Python execution"): + local_policy, permission_error = check_local_execution_permission( + context, + "Python execution", + ) + if permission_error: return permission_error - if not is_local_runtime(context): + if local_policy is None: return "Error executing code: only local runtime is supported." + sandboxed = local_policy.requires_sandbox sb = get_local_booter() + if not isinstance(sb.python, LocalPythonComponent): + return "Error executing code: local Python component is unavailable." effective_timeout = ( min(timeout, context.tool_call_timeout) if timeout > 0 else context.tool_call_timeout ) + if sandboxed: + effective_timeout = min(effective_timeout, 300) try: current_workspace_root = await workspace_root_for_context(context) current_workspace_root.mkdir(parents=True, exist_ok=True) + sandbox_roots = {} + if sandboxed and local_policy.filesystem_scope == "workspace": + umo = context.context.event.unified_msg_origin + sandbox_roots = { + "readable_roots": _read_allowed_roots(umo, current_workspace_root), + "writable_roots": _write_allowed_roots( + umo, + current_workspace_root, + include_installed_skills=context.context.event.role == "admin", + ), + } result = await sb.python.exec( code, timeout=effective_timeout, silent=silent, cwd=str(current_workspace_root), + sandboxed=sandboxed, + allow_network=local_policy.allow_network, + filesystem_scope=local_policy.filesystem_scope, + **sandbox_roots, ) return await handle_result(result, context.context.event) except Exception as e: diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index 69d177769e..ac1d17126a 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -12,12 +12,14 @@ from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.booters.local import LocalShellComponent -from astrbot.core.computer.computer_client import get_booter +from astrbot.core.computer.computer_client import get_booter, get_local_booter from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path from ..registry import builtin_tool +from .fs import _read_allowed_roots, _write_allowed_roots from .util import ( - check_admin_permission, + check_local_execution_permission, + get_local_permission_policy, is_local_runtime, workspace_root_for_context, ) @@ -99,8 +101,13 @@ async def call( env: dict[str, Any] | None = None, yield_time_ms: int = 10_000, ) -> ToolExecResult: - if permission_error := check_admin_permission(context, "Shell execution"): + local_policy, permission_error = check_local_execution_permission( + context, + "Shell execution", + ) + if permission_error: return permission_error + sandboxed = bool(local_policy and local_policy.requires_sandbox) sb = await get_booter( context.context.context, @@ -123,17 +130,51 @@ async def call( creator_id = context.context.event.get_sender_id() if not creator_id: return "Error executing command: sender identity is unavailable." + creator_is_admin = context.context.event.role == "admin" + sandbox_roots = {} + if local_policy and local_policy.filesystem_scope == "workspace": + umo = context.context.event.unified_msg_origin + sandbox_roots = { + "readable_roots": _read_allowed_roots( + umo, current_workspace_root + ), + "writable_roots": _write_allowed_roots( + umo, + current_workspace_root, + include_installed_skills=context.context.event.role + == "admin", + ), + } started_at = monotonic() result = await sb.shell.exec_managed( command, owner_id=context.context.event.unified_msg_origin, creator_id=creator_id, - creator_is_admin=context.context.event.role == "admin", - sandboxed=False, + creator_is_admin=creator_is_admin, + sandboxed=sandboxed, + permission_check=lambda: ( + is_local_runtime(context) + and get_local_permission_policy(context) == local_policy + # The original event role does not reflect admin removal. + and ( + not creator_is_admin + or str(creator_id) + in context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ).get("admins_id", []) + ) + ), + allow_network=( + local_policy.allow_network if local_policy else True + ), + filesystem_scope=( + local_policy.filesystem_scope if local_policy else "host" + ), cwd=cwd, env=env, - timeout=timeout, + timeout=min(timeout or 300, 300) if sandboxed else timeout, yield_time_ms=0 if background else yield_time_ms, + **sandbox_roots, ) elapsed_seconds = monotonic() - started_at if result.get("session_closed") and result.get("status") in { @@ -185,7 +226,8 @@ class LocalExecuteShellTool(ExecuteShellTool): description: str = ( "Execute a command in the shell. If it is still running after " - "yield_time_ms, the tool returns a managed shell session ID." + "yield_time_ms, the tool returns a managed shell session ID. " + "Restricted Linux and macOS calls run inside an operating-system sandbox." ) parameters: dict = field( default_factory=lambda: { @@ -336,19 +378,17 @@ async def call( Returns: JSON session operation result or a user-facing error. """ - if permission_error := check_admin_permission( + _, permission_error = check_local_execution_permission( context, "Shell session management", - ): + ) + if permission_error and action != "terminate": return permission_error - if not is_local_runtime(context): + if not is_local_runtime(context) and action != "terminate": return "Error managing shell session: only local runtime is supported." try: - sb = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) + sb = get_local_booter() if not isinstance(sb.shell, LocalShellComponent): return "Error managing shell session: local shell component is unavailable." diff --git a/astrbot/core/tools/computer_tools/util.py b/astrbot/core/tools/computer_tools/util.py index 1f1a0dd9ad..0fdfc20094 100644 --- a/astrbot/core/tools/computer_tools/util.py +++ b/astrbot/core/tools/computer_tools/util.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass from pathlib import Path +from typing import Literal from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.computer.process_sandbox import create_process_sandbox +from astrbot.core.config.default import get_local_permission_defaults from astrbot.core.db import BaseDatabase from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path from astrbot.core.workspace import ( @@ -10,6 +14,26 @@ ) +@dataclass(frozen=True) +class LocalPermissionPolicy: + """Resolved Local computer permissions for one caller. + + Args: + allow_execution: Whether Shell and Python execution is allowed. + allow_network: Whether the execution environment may use the network. + filesystem_scope: Host or workspace access, or none to disable Local tools. + """ + + allow_execution: bool + allow_network: bool + filesystem_scope: Literal["none", "workspace", "host"] + + @property + def requires_sandbox(self) -> bool: + """Return whether execution needs operating-system isolation.""" + return not self.allow_network or self.filesystem_scope != "host" + + def workspace_root(umo: str) -> Path: """Return the legacy workspace root for compatibility. @@ -54,6 +78,76 @@ def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool: return runtime == "local" +def get_local_permission_policy( + context: ContextWrapper[AstrAgentContext], +) -> LocalPermissionPolicy: + """Resolve the Local permission policy for the caller's role. + + Args: + context: Tool call context. + + Returns: + Normalized policy. Unknown roles use the member policy. + """ + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + role = "admin" if context.context.event.role == "admin" else "member" + defaults = get_local_permission_defaults()[role] + + permissions = provider_settings.get("computer_use_local_permissions") + role_policy = permissions.get(role) if isinstance(permissions, dict) else None + if not isinstance(role_policy, dict): + role_policy = {} + if role == "member" and not isinstance(permissions, dict): + defaults["allow_execution"] = not provider_settings.get( + "computer_use_require_admin", + True, + ) + + filesystem_scope = role_policy.get("filesystem_scope", defaults["filesystem_scope"]) + if filesystem_scope not in {"none", "workspace", "host"}: + filesystem_scope = defaults["filesystem_scope"] + allow_execution = ( + filesystem_scope != "none" + and role_policy.get("allow_execution", defaults["allow_execution"]) is True + ) + allow_network = ( + allow_execution + and role_policy.get("allow_network", defaults["allow_network"]) is True + ) + return LocalPermissionPolicy( + allow_execution=allow_execution, + allow_network=allow_network, + filesystem_scope=filesystem_scope, + ) + + +def check_local_file_permission( + context: ContextWrapper[AstrAgentContext], +) -> str | None: + """Reject file tools when Local access is disabled for the caller's role. + + Args: + context: Tool call context. + + Returns: + A permission error, or None when the file tool may proceed. + """ + if ( + is_local_runtime(context) + and get_local_permission_policy(context).filesystem_scope == "none" + ): + return ( + "error: Permission denied. Local computer tools are disabled for this " + "user role. Enable Local computer access for this role in AstrBot " + "WebUI -> Config -> Normal Config -> AI -> Agent Computer Use -> " + "Local Permission Policies." + ) + return None + + def check_admin_permission( context: ContextWrapper[AstrAgentContext], operation_name: str ) -> str | None: @@ -69,3 +163,40 @@ def check_admin_permission( f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." ) return None + + +def check_local_execution_permission( + context: ContextWrapper[AstrAgentContext], + operation_name: str, +) -> tuple[LocalPermissionPolicy | None, str | None]: + """Resolve whether an execution tool needs an operating-system sandbox. + + Args: + context: Tool call context. + operation_name: User-facing name included in permission errors. + + Returns: + Resolved Local policy and an optional error. Non-Local runtimes return + no policy because their existing administrator gate is unchanged. + """ + if not is_local_runtime(context): + return None, check_admin_permission(context, operation_name) + policy = get_local_permission_policy(context) + if not policy.allow_execution: + return policy, ( + f"error: Permission denied. {operation_name} is disabled by the " + "Local permission policy for this user role. Enable Local computer " + "access and `Execute code` " + "for this role in AstrBot WebUI -> Config -> Normal Config -> AI -> " + "Agent Computer Use -> Local Permission Policies." + ) + if policy.requires_sandbox: + try: + create_process_sandbox() + except RuntimeError as exc: + return policy, ( + "error: Permission denied. Restricted Local execution is unavailable: " + f"{exc} Select `Third-party sandbox` under AstrBot WebUI -> Config -> " + "Normal Config -> AI -> Agent Computer Use -> Computer Use Runtime." + ) + return policy, None diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index ca6f21e9b5..344e0d3c7e 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -21,6 +21,7 @@ from astrbot.core.tools.computer_tools.fs import _remote_basename from astrbot.core.tools.computer_tools.util import ( check_admin_permission, + get_local_permission_policy, is_local_runtime, workspace_root, workspace_root_for_context, @@ -53,14 +54,9 @@ def _is_path_within(path: Path, roots: tuple[Path, ...]) -> bool: def _is_restricted_local_env(context: ContextWrapper[AstrAgentContext]) -> bool: - if not is_local_runtime(context): - return False - cfg = context.context.context.get_config( - umo=context.context.event.unified_msg_origin + return is_local_runtime(context) and ( + get_local_permission_policy(context).filesystem_scope != "host" ) - provider_settings = cfg.get("provider_settings", {}) - require_admin = provider_settings.get("computer_use_require_admin", True) - return require_admin and context.context.event.role != "admin" def _can_send_local_file( @@ -183,6 +179,12 @@ async def _resolve_path_from_sandbox( f"Blocked path: {local_candidate}." ) + # Local runtime has no separate sandbox: the workspace and local-file + # branches above already enforced the caller's permissions, so probing + # the host shell here would bypass them and expose host paths. + if is_local_runtime(context): + raise FileNotFoundError(f"{component_type} path does not exist: {path}") + try: sb = await get_booter( context.context.context, diff --git a/astrbot/dashboard/api/app.py b/astrbot/dashboard/api/app.py index d4766ce4c7..5eca6e2f80 100644 --- a/astrbot/dashboard/api/app.py +++ b/astrbot/dashboard/api/app.py @@ -100,8 +100,9 @@ def create_dashboard_asgi_app( app.state.jwt_secret = jwt_secret app.state.dashboard_static_folder = static_folder log_broker = getattr(core_lifecycle, "log_broker", None) or LogBroker() + stats = StatService(db, core_lifecycle, core_lifecycle.astrbot_config) app.state.services = SimpleNamespace( - config_profiles=ConfigProfileService(core_lifecycle, db), + config_profiles=ConfigProfileService(core_lifecycle, db, runtime=stats.runtime), config_display=ConfigDisplayService(core_lifecycle), config_files=ConfigFileService(core_lifecycle), config_routes=ConfigRoutingService(core_lifecycle), @@ -129,7 +130,7 @@ def create_dashboard_asgi_app( open_api=OpenApiService(db, core_lifecycle), sessions=SessionManagementService(core_lifecycle, db), skills=SkillsService(core_lifecycle), - stats=StatService(db, core_lifecycle, core_lifecycle.astrbot_config), + stats=stats, subagents=SubAgentService(core_lifecycle), t2i=T2iService(core_lifecycle), tools=ToolsService(core_lifecycle), diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py index 21e5d60357..47d813de02 100644 --- a/astrbot/dashboard/services/config_service.py +++ b/astrbot/dashboard/services/config_service.py @@ -11,6 +11,8 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from astrbot.core import file_token_service, logger +from astrbot.core.computer import computer_client +from astrbot.core.computer.booters.local import LocalShellComponent from astrbot.core.config.agent_runner import normalize_agent_runner from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.default import ( @@ -19,6 +21,7 @@ CONFIG_METADATA_3_SYSTEM, DEFAULT_CONFIG, DEFAULT_VALUE_MAP, + get_local_permission_defaults, ) from astrbot.core.config.i18n_utils import ConfigMetadataI18n from astrbot.core.core_lifecycle import AstrBotCoreLifecycle @@ -203,7 +206,27 @@ def sanitize_filename(name: str) -> str: return _sanitize_filename(name) -def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]: +def validate_config( + data, + schema: dict, + is_core: bool, + *, + runtime: dict | None = None, + current_config: dict | None = None, +) -> tuple[list[str], dict]: + """Validate configuration values and normalize linked Local permissions. + + Args: + data: Submitted configuration, normalized in place. + schema: Configuration metadata used for validation. + is_core: Whether this is a core configuration rather than a plugin. + runtime: Startup runtime snapshot for platform-specific validation. + current_config: Existing configuration whose unchanged Local policies + may be retained when saving unrelated settings. + + Returns: + Validation errors and the normalized configuration. + """ errors = [] def validate(data: dict, metadata: dict = schema, path="") -> None: @@ -302,6 +325,98 @@ def validate(data: dict, metadata: dict = schema, path="") -> None: **schema["misc_config_group"]["metadata"], } validate(data, meta_all) + provider_settings = data.get("provider_settings", {}) + defaults = get_local_permission_defaults(runtime.get("os") if runtime else None) + permissions = ( + provider_settings.get("computer_use_local_permissions", {}) + if isinstance(provider_settings, dict) + else {} + ) + submitted_permissions = copy.deepcopy(permissions) + if not isinstance(permissions, dict): + errors.append("Local computer permissions must be an object.") + else: + for role in ("member", "admin"): + if role not in permissions: + continue + policy = permissions[role] + if not isinstance(policy, dict): + errors.append( + f"Local computer permissions for {role} must be an object." + ) + continue + for key in ("allow_execution", "allow_network"): + if key in policy and not isinstance(policy[key], bool): + errors.append( + f"Local permission {role}.{key} must be a boolean." + ) + scope = policy.get( + "filesystem_scope", defaults[role]["filesystem_scope"] + ) + if scope not in ("none", "workspace", "host"): + errors.append( + f"Invalid local filesystem scope for {role}: {scope}." + ) + if scope == "none": + policy["allow_execution"] = False + policy["allow_network"] = False + elif ( + policy.get("allow_execution", defaults[role]["allow_execution"]) + is False + ): + policy["allow_network"] = False + + if ( + not errors + and runtime is not None + and isinstance(provider_settings, dict) + and provider_settings.get("computer_use_runtime") == "local" + and runtime["sandbox"]["status"] != "detected" + ): + old_settings = (current_config or {}).get("provider_settings", {}) + old_permissions = old_settings.get("computer_use_local_permissions", {}) + was_local = old_settings.get("computer_use_runtime") == "local" + for role in ("member", "admin"): + # Keep unchanged legacy policies, but check both roles when + # activating Local access or creating a profile. + if was_local and submitted_permissions.get( + role, {} + ) == old_permissions.get(role, {}): + continue + policy = {**defaults[role], **permissions.get(role, {})} + scope = policy["filesystem_scope"] + if scope == "none": + continue + unsupported = runtime["sandbox"]["status"] == "unsupported" + if not ( + (unsupported and scope == "workspace") + or ( + policy["allow_execution"] + and (scope == "workspace" or not policy["allow_network"]) + ) + ): + continue + if unsupported: + reason = f"Local isolation is not supported on {runtime['os']}." + else: + dependency = ( + "Seatbelt (/usr/bin/sandbox-exec)" + if runtime["sandbox"]["backend"] == "seatbelt" + else "bubblewrap (bwrap)" + ) + if runtime["sandbox"]["status"] == "unavailable": + detail = runtime["sandbox"].get( + "error", "Sandbox startup failed." + ) + reason = ( + f"{dependency} is installed but cannot start a sandbox: " + f"{detail} Restricted Local execution is unavailable. " + "Check system security policies or container restrictions, " + "then restart AstrBot to check again." + ) + else: + reason = f"Missing {dependency}; restricted Local execution is unavailable." + errors.append(f"Local permission {role}: {reason}") else: validate(data, schema) @@ -327,6 +442,23 @@ def _log_computer_config_changes( new_runtime, ) + old_permissions = old_ps.get("computer_use_local_permissions", {}) + new_permissions = new_ps.get("computer_use_local_permissions", {}) + for role in ("member", "admin"): + old_role = old_permissions.get(role, {}) + new_role = new_permissions.get(role, {}) + for key in ("allow_execution", "allow_network", "filesystem_scope"): + old_value = old_role.get(key) + new_value = new_role.get(key) + if old_value != new_value: + log_info( + "[Computer] Config changed: local_permissions.%s.%s %s -> %s", + role, + key, + old_value, + new_value, + ) + old_sandbox = old_ps.get("sandbox", {}) new_sandbox = new_ps.get("sandbox", {}) all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys()) @@ -425,9 +557,21 @@ def save_config( post_config: dict, config: AstrBotConfig, is_core: bool = False, + *, + runtime: dict | None = None, ) -> None: + """Validate and persist a dashboard configuration update. + + Args: + post_config: Submitted configuration to validate and save. + config: Existing configuration and persistence target. + is_core: Whether this is a core configuration rather than a plugin. + runtime: Startup runtime snapshot supplied by the profile service. + + Raises: + ValueError: If configuration validation fails. + """ if is_core: - _log_computer_config_changes(dict(config), post_config) post_config["agent_runner"] = normalize_agent_runner( post_config.get("agent_runner") ) @@ -438,6 +582,8 @@ def save_config( post_config, CONFIG_METADATA_2, is_core, + runtime=runtime, + current_config=dict(config), ) else: errors, post_config = validate_config( @@ -452,6 +598,8 @@ def save_config( if errors: raise ValueError(f"格式校验未通过: {errors}") + if is_core: + _log_computer_config_changes(dict(config), post_config) config.save_config(post_config) @@ -460,10 +608,13 @@ def __init__( self, core_lifecycle: AstrBotCoreLifecycle, db: BaseDatabase | None = None, + *, + runtime: dict, ) -> None: self.core_lifecycle = core_lifecycle self.acm = core_lifecycle.astrbot_config_mgr self.db = db + self.runtime = runtime def get_profile_schema(self) -> dict: return { @@ -525,6 +676,7 @@ async def create_profile( Raises: ApiError: If caller attempts to define administrator IDs without scope. + ValueError: If configuration validation fails. """ if ( not allow_admin_id_change @@ -541,6 +693,11 @@ async def create_profile( profile_config["agent_runner"] = normalize_agent_runner( profile_config["agent_runner"] ) + errors, profile_config = validate_config( + profile_config, CONFIG_METADATA_2, is_core=True, runtime=self.runtime + ) + if errors: + raise ValueError(f"Configuration validation failed: {errors}") conf_id = await self.acm.create_conf( name=name, config=profile_config, @@ -607,7 +764,7 @@ async def update_profile( Raises: ApiError: If admin IDs change without permission or TOTP is invalid. - ValueError: If the requested config profile does not exist. + ValueError: If the profile does not exist or validation fails. """ if config_id not in self.acm.confs: raise ValueError(f"Config file {config_id} does not exist") @@ -644,7 +801,12 @@ async def update_profile( _set_nested_value(config, ("dashboard", "totp", "recovery_code_hash"), "") set_pending_totp_secret(None) - save_config(config, self.acm.confs[config_id], is_core=True) + save_config( + config, self.acm.confs[config_id], is_core=True, runtime=self.runtime + ) + booter = computer_client.local_booter + if booter is not None and isinstance(booter.shell, LocalShellComponent): + await booter.shell.shutdown_sessions(invalid_only=True) if protected_2fa_changed and self.db is not None: await revoke_user_trusted_devices(self.db) await self.core_lifecycle.reload_pipeline_scheduler(config_id) diff --git a/astrbot/dashboard/services/stat_service.py b/astrbot/dashboard/services/stat_service.py index 6ebc3bfba8..4ac60793c4 100644 --- a/astrbot/dashboard/services/stat_service.py +++ b/astrbot/dashboard/services/stat_service.py @@ -2,7 +2,10 @@ import ast import asyncio +import platform import re +import shutil +import tempfile import threading import time import traceback @@ -16,6 +19,7 @@ from sqlmodel import col, func, select from astrbot.core import DEMO_MODE, logger +from astrbot.core.computer.process_sandbox import SandboxSpec, create_process_sandbox from astrbot.core.config import VERSION from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.core_lifecycle import AstrBotCoreLifecycle @@ -30,7 +34,7 @@ is_desktop_session_auth_enabled, ) from astrbot.core.umo_alias import build_umo_alias_map, serialize_umo_alias -from astrbot.core.utils.astrbot_path import get_astrbot_path +from astrbot.core.utils.astrbot_path import get_astrbot_path, get_astrbot_temp_path from astrbot.core.utils.auth_password import ( is_default_dashboard_password, is_md5_dashboard_password, @@ -60,6 +64,52 @@ def __init__( self.config = config self.storage_cleaner = StorageCleaner(config) + # Probe sandbox startup once; restart AstrBot to refresh this snapshot. + system = platform.system().lower() + sandbox = {"backend": None, "status": "unsupported"} + if system == "linux": + sandbox = { + "backend": "bubblewrap", + "status": "detected" if shutil.which("bwrap") else "missing", + } + elif system == "darwin": + sandbox = { + "backend": "seatbelt", + "status": ( + "detected" + if shutil.which("sandbox-exec", path="/usr/bin") + == "/usr/bin/sandbox-exec" + else "missing" + ), + } + if sandbox["status"] == "detected": + try: + temp_root = Path(get_astrbot_temp_path()) + temp_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="sandbox-probe-", dir=temp_root + ) as workspace: + result = create_process_sandbox().run( + ["/bin/sh", "-c", ":"], + SandboxSpec(workspace=Path(workspace)), + timeout=5, + output_limit=1024, + ) + if result.returncode != 0: + raise RuntimeError( + result.stderr.decode("utf-8", errors="replace").strip() + or f"Sandbox probe exited with code {result.returncode}." + ) + except (OSError, RuntimeError) as exc: + sandbox.update( + status="unavailable", error=str(exc)[:1024] or type(exc).__name__ + ) + self.runtime = { + "os": system, + "arch": platform.machine(), + "sandbox": sandbox, + } + async def restart_core(self) -> None: if DEMO_MODE: raise StatServiceError( @@ -107,6 +157,7 @@ async def get_version(self) -> dict: "change_pwd_hint": False, "md5_pwd_hint": False, "password_upgrade_required": False, + "runtime": self.runtime, } storage_upgraded = await is_password_storage_upgraded( self.db_helper, @@ -124,6 +175,7 @@ async def get_version(self) -> dict: "change_pwd_hint": await self.is_default_cred(), "md5_pwd_hint": md5_pwd_hint, "password_upgrade_required": not storage_upgraded, + "runtime": self.runtime, } async def get_public_versions( diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 0d46031868..973b1971bb 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -2647,7 +2647,8 @@ export const getProviderTokenStats = (opti }; /** - * Get AstrBot version + * Get AstrBot version and runtime information + * Runtime information is detected once at application startup. Restart AstrBot after installing sandbox dependencies to refresh it. */ export const getVersion = (options?: OptionsLegacyParser) => { return (options?.client ?? client).get({ diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 4ac2fee95c..37cad88028 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -596,6 +596,41 @@ export type ReorderRequest = { }>; }; +/** + * The AstrBot backend runtime, including when running inside a container. Values are captured at application startup. + */ +export type RuntimeInfo = { + /** + * Lowercase platform.system() value, commonly linux, darwin, or windows. + */ + os: string; + /** + * Unmodified platform.machine() value, such as x86_64, AMD64, arm64, or aarch64. May be empty if unknown. + */ + arch: string; + /** + * Local process sandbox startup check, captured when AstrBot starts. It does not verify DNS resolution or every permitted operation. + */ + sandbox: { + backend: ('bubblewrap' | 'seatbelt') | null; + /** + * detected means the executable was found and a minimal workspace sandbox launched successfully; missing means the corresponding executable was not found; unavailable means it was found but sandbox startup failed; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language. + */ + status: 'detected' | 'missing' | 'unavailable' | 'unsupported'; + /** + * Bounded startup error detail, included when status is unavailable. Restart AstrBot after fixing the environment to refresh the check. + */ + error?: string; + }; +}; + +export type backend = 'bubblewrap' | 'seatbelt'; + +/** + * detected means the executable was found and a minimal workspace sandbox launched successfully; missing means the corresponding executable was not found; unavailable means it was found but sandbox startup failed; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language. + */ +export type status = 'detected' | 'missing' | 'unavailable' | 'unsupported'; + export type SessionGroupRequest = { name?: string; umos?: Array<(string)>; @@ -3290,7 +3325,11 @@ export type GetProviderTokenStatsResponse = (SuccessEnvelope); export type GetProviderTokenStatsError = unknown; -export type GetVersionResponse = (SuccessEnvelope); +export type GetVersionResponse = ((SuccessEnvelope & { + data?: { + runtime: RuntimeInfo; + }; +})); export type GetVersionError = unknown; diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 2ea5b3086b..5307a1840d 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -43,6 +43,7 @@ import { type PluginValidateRepoRequest, type PluginConfigFileDeleteRequest, type ProviderConfigRequest, + type RuntimeInfo, type BatchSessionProviderRequest, type BatchSessionServiceRequest, type SetupAuthRequest, @@ -120,6 +121,7 @@ export interface VersionData { change_pwd_hint?: boolean; md5_pwd_hint?: boolean; password_upgrade_required?: boolean; + runtime?: RuntimeInfo; [key: string]: unknown; } diff --git a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue index 3fc2906da2..5d44dbc0f4 100644 --- a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue +++ b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue @@ -402,8 +402,8 @@ export default { padding: 0; } -.config-standard-section__groups :deep(.config-input > *), -:deep(.config-product-groups .config-input > *) { +.config-standard-section__groups :deep(.config-input > :not(.config-field--full-width)), +:deep(.config-product-groups .config-input > :not(.config-field--full-width)) { width: 100%; max-width: 270px; } diff --git a/dashboard/src/components/shared/AstrBotConfigV4.vue b/dashboard/src/components/shared/AstrBotConfigV4.vue index 379cd52969..4de6ed5dbe 100644 --- a/dashboard/src/components/shared/AstrBotConfigV4.vue +++ b/dashboard/src/components/shared/AstrBotConfigV4.vue @@ -280,7 +280,7 @@ function getSpecialSubtype(value) { class="config-item" > - + {{ getItemDescription(itemKey, itemMeta) }} @@ -293,7 +293,7 @@ function getSpecialSubtype(value) { - + -
+
+