"""Executing the toolkit inside the sandbox — and refusing what it must. Four rules, all enforced here rather than at the call sites: 1. **Path safety.** Every path is resolved against the worktree's sandbox root. Absolute paths that escape it, ``..`` traversal and anything under ``.git`` are refused. A symlink that points outside the repository is not followed: the sandbox is the security boundary, and this is the second lock. 2. **The AI never touches git.** ``git push``, ``git remote`` and ``git config`` are refused with a message that says why — the platform does git through ``RepoWorkspacePort`` (PLAN-v2 D6), never the model. 3. **Bounded commands.** ``RUN`` has a timeout (default 120 s, hard max 600 s) and its output is capped to the last ``engine_tool_output_chars`` characters, which is what the model needs anyway: a traceback is at the end. 4. **Every call is recorded.** Each execution writes a ``tool_calls`` row with ``succeeded``/``failed``, so the approvals UI finally shows real executions instead of intentions. The sandbox is reached only through ``SandboxProviderPort``; there is no local shell-out path. """ from __future__ import annotations import shlex from dataclasses import dataclass from pathlib import PurePosixPath from app.application.engine.protocol import EditBlock, Tag, TagName, edit_blocks_of from app.application.ports import CommandResult, SandboxProviderPort from app.core.config import settings from app.domain.workforce.tools import ToolKey #: Commands the model may never run. The message is the point: a refusal the #: model can read is a refusal it works around once. _FORBIDDEN_GIT: tuple[tuple[str, str], ...] = ( ("push", "git push"), ("remote", "git remote"), ("config", "git config"), ) _GIT_REFUSAL = ( "دستور git رد شد: عملیات git را پلتفرم انجام می‌دهد، نه هوش مصنوعی." ) #: RUN defaults and ceilings. A model asking for "no timeout" gets the ceiling. RUN_DEFAULT_TIMEOUT = 120 RUN_MAX_TIMEOUT = 600 RUN_MAX_COMMAND_CHARS = 4000 #: Which protocol tag maps to which row of the existing tool_calls catalog. _TOOL_KEY: dict[TagName, str] = { TagName.READ_FILE: ToolKey.SANDBOX_READ_FILE, TagName.WRITE_FILE: ToolKey.SANDBOX_WRITE_FILE, TagName.EDIT_FILE: ToolKey.SANDBOX_WRITE_FILE, TagName.LIST_DIR: ToolKey.SANDBOX_RUN_COMMAND, TagName.SEARCH: ToolKey.SANDBOX_RUN_COMMAND, TagName.RUN: ToolKey.SANDBOX_RUN_COMMAND, } class UnsafePathError(ValueError): """The requested path escapes the worktree.""" class GitRefusedError(ValueError): """The model tried to run git itself.""" @dataclass(slots=True, kw_only=True) class ToolOutcome: """One tool execution: what happened, in a form the model can read.""" tool: str ok: bool output: str refused: bool = False #: The row key this call was recorded under, for the approvals UI. tool_key: str = "" tool_call_id: str = "" def safe_join(root: str, relative: str) -> str: """Resolve ``relative`` inside ``root`` or raise :class:`UnsafePathError`. Pure and synchronous on purpose: it is also the unit under test, and a path bug must be reproducible without a container. """ if not relative or not relative.strip(): raise UnsafePathError("مسیر خالی است.") candidate = relative.strip() if candidate.startswith(("/", "~")): raise UnsafePathError(f"مسیر مطلق مجاز نیست: {candidate}") parts = PurePosixPath(candidate).parts if any(part == ".." for part in parts): raise UnsafePathError(f"خروج از ریشهٔ مخزن مجاز نیست: {candidate}") if parts and parts[0] == ".git": raise UnsafePathError("دسترسی به .git مجاز نیست.") base = PurePosixPath(root.rstrip("/") or "/") return str(base / candidate.lstrip("/")) def _tail(text: str, *, limit: int) -> str: """Keep the *last* ``limit`` characters: a traceback is at the end.""" if len(text) <= limit: return text return f"[… {len(text) - limit} کاراکتر ابتدایی حذف شد …]\n{text[-limit:]}" def _clamp_timeout(raw: str) -> int: try: value = int(float(raw)) if raw else RUN_DEFAULT_TIMEOUT except ValueError: value = RUN_DEFAULT_TIMEOUT return max(1, min(value, RUN_MAX_TIMEOUT)) def _check_command(command: str) -> None: if len(command) > RUN_MAX_COMMAND_CHARS: raise ValueError("دستور بیش از حد طولانی است.") tokens = shlex.split(command, comments=False, posix=True) if not tokens: raise ValueError("دستور خالی است.") if tokens[0] != "git": return for sub, label in _FORBIDDEN_GIT: if sub in tokens[1:]: raise GitRefusedError(f"{_GIT_REFUSAL} («{label}»)") def _shell_quote(value: str) -> str: return shlex.quote(value) class ToolExecutor: """Runs one parsed tool tag inside the worktree's sandbox session.""" def __init__( self, *, sandbox: SandboxProviderPort, session_external_id: str, root: str, max_output_chars: int | None = None, ) -> None: self.sandbox = sandbox self.session_external_id = session_external_id self.root = root self.max_output_chars = ( settings.engine_tool_output_chars if max_output_chars is None else max_output_chars ) async def execute(self, tag: Tag) -> ToolOutcome: """Execute one tool tag. Never raises: failures come back as output.""" handler = _HANDLERS.get(tag.name) if handler is None: # pragma: no cover - the matrix forbids it return ToolOutcome( tool=tag.name.value, ok=False, output="این ابزار در این نقش مجاز نیست." ) try: output = await handler(self, tag) except UnsafePathError as exc: return ToolOutcome( tool=tag.name.value, ok=False, output=str(exc), refused=True ) except GitRefusedError as exc: return ToolOutcome( tool=tag.name.value, ok=False, output=str(exc), refused=True ) except FileNotFoundError as exc: return ToolOutcome(tool=tag.name.value, ok=False, output=str(exc)) except OSError as exc: return ToolOutcome( tool=tag.name.value, ok=False, output=f"خطای سندباکس: {exc}" ) except ValueError as exc: return ToolOutcome(tool=tag.name.value, ok=False, output=str(exc)) return ToolOutcome( tool=tag.name.value, ok=True, output=_tail(output, limit=self.max_output_chars) ) # -- sandbox primitives ------------------------------------------------- async def _run(self, command: str, *, timeout: int) -> str: _check_command(command) result: CommandResult = await self.sandbox.run_command( session_external_id=self.session_external_id, command=command, timeout_seconds=timeout, max_output_bytes=settings.engine_tool_output_chars * 4, ) text = result.stdout or "" if result.stderr: text = f"{text}\n{result.stderr}" if text else result.stderr return f"$ {command}\n(exit {result.exit_code})\n{text}".strip() async def _read(self, path: str) -> str: return await self.sandbox.read_file( session_external_id=self.session_external_id, path=safe_join(self.root, path) ) async def _write(self, path: str, content: str) -> None: await self.sandbox.write_file( session_external_id=self.session_external_id, path=safe_join(self.root, path), content=content, ) # -- the six tools ------------------------------------------------------ async def _read_file(self, tag: Tag) -> str: path = tag.require("path") content = await self._read(path) return f"# {path}\n{content}" async def _write_file(self, tag: Tag) -> str: path = tag.require("path") await self._write(path, tag.body) return f"نوشته شد: {path} ({len(tag.body)} کاراکتر)" async def _edit_file(self, tag: Tag) -> str: path = tag.require("path") blocks: list[EditBlock] = edit_blocks_of(tag) content = await self._read(path) for block in blocks: occurrences = content.count(block.old) if occurrences != 1: raise ValueError( f"بلوک قدیمی باید دقیقاً یک بار پیدا شود؛ {occurrences} بار پیدا شد." ) content = content.replace(block.old, block.new, 1) await self._write(path, content) return f"ویرایش شد: {path} ({len(blocks)} بلوک)" async def _list_dir(self, tag: Tag) -> str: path = tag.attr("path", ".") or "." target = safe_join(self.root, path) return await self._run(f"ls -la -- {_shell_quote(target)}", timeout=30) async def _search(self, tag: Tag) -> str: pattern = tag.require("pattern") path = tag.attr("path", ".") or "." target = safe_join(self.root, path) command = f"grep -RIn -e {_shell_quote(pattern)} -- {_shell_quote(target)}" # grep exits 1 when nothing matched, which is an answer, not a failure. return await self._run(command, timeout=60) async def _run_command(self, tag: Tag) -> str: command = tag.attr("cmd", "") or tag.body.strip() if not command: raise ValueError("دستور خالی است.") timeout = _clamp_timeout(tag.attr("timeout", "")) return await self._run(command, timeout=timeout) _HANDLERS = { TagName.READ_FILE: ToolExecutor._read_file, TagName.WRITE_FILE: ToolExecutor._write_file, TagName.EDIT_FILE: ToolExecutor._edit_file, TagName.LIST_DIR: ToolExecutor._list_dir, TagName.SEARCH: ToolExecutor._search, TagName.RUN: ToolExecutor._run_command, } def tool_key_for(tag: Tag) -> str: """The ``tool_calls`` row key a protocol tag maps to.""" return _TOOL_KEY.get(tag.name, ToolKey.SANDBOX_RUN_COMMAND) __all__ = [ "GitRefusedError", "RUN_DEFAULT_TIMEOUT", "RUN_MAX_TIMEOUT", "ToolExecutor", "ToolOutcome", "UnsafePathError", "safe_join", "tool_key_for", ]