libs/langchain_v1/langchain/agents/middleware/shell_tool.py PYTHON 950 lines View on github.com → Search inside
1"""Middleware that exposes a persistent shell tool to agents."""23from __future__ import annotations45import contextlib6import logging7import os8import queue9import signal10import subprocess11import tempfile12import threading13import time14import uuid15import weakref16from dataclasses import dataclass, field17from pathlib import Path18from typing import TYPE_CHECKING, Annotated, Any, Literal, cast, overload1920from langchain_core.messages import ToolMessage21from langchain_core.runnables import run_in_executor22from langchain_core.tools.base import ToolException23from langgraph.channels.untracked_value import UntrackedValue24from pydantic import BaseModel, model_validator25from pydantic.json_schema import SkipJsonSchema26from typing_extensions import NotRequired, override2728from langchain.agents.middleware._execution import (29    SHELL_TEMP_PREFIX,30    BaseExecutionPolicy,31    CodexSandboxExecutionPolicy,32    DockerExecutionPolicy,33    HostExecutionPolicy,34)35from langchain.agents.middleware._redaction import (36    PIIDetectionError,37    PIIMatch,38    RedactionRule,39    ResolvedRedactionRule,40)41from langchain.agents.middleware.types import (42    AgentMiddleware,43    AgentState,44    ContextT,45    PrivateStateAttr,46    ResponseT,47)48from langchain.tools import ToolRuntime, tool4950if TYPE_CHECKING:51    from collections.abc import Mapping, Sequence5253    from langgraph.runtime import Runtime545556LOGGER = logging.getLogger(__name__)57_DONE_MARKER_PREFIX = "__LC_SHELL_DONE__"5859DEFAULT_TOOL_DESCRIPTION = (60    "Execute a shell command inside a persistent session. Before running a command, "61    "confirm the working directory is correct (e.g., inspect with `ls` or `pwd`) and ensure "62    "any parent directories exist. Prefer absolute paths and quote paths containing spaces, "63    'such as `cd "/path/with spaces"`. Chain multiple commands with `&&` or `;` instead of '64    "embedding newlines. Avoid unnecessary `cd` usage unless explicitly required so the "65    "session remains stable. Outputs may be truncated when they become very large, and long "66    "running commands will be terminated once their configured timeout elapses."67)68SHELL_TOOL_NAME = "shell"697071def _cleanup_resources(72    session: ShellSession, tempdir: tempfile.TemporaryDirectory[str] | None, timeout: float73) -> None:74    with contextlib.suppress(Exception):75        session.stop(timeout)76    if tempdir is not None:77        with contextlib.suppress(Exception):78            tempdir.cleanup()798081@dataclass82class _SessionResources:83    """Container for per-run shell resources."""8485    session: ShellSession86    tempdir: tempfile.TemporaryDirectory[str] | None87    policy: BaseExecutionPolicy88    finalizer: weakref.finalize = field(init=False, repr=False)  # type: ignore[type-arg]8990    def __post_init__(self) -> None:91        self.finalizer = weakref.finalize(92            self,93            _cleanup_resources,94            self.session,95            self.tempdir,96            self.policy.termination_timeout,97        )9899100class ShellToolState(AgentState[ResponseT]):101    """Agent state extension for tracking shell session resources.102103    Type Parameters:104        ResponseT: The type of the structured response. Defaults to `Any`.105    """106107    shell_session_resources: NotRequired[108        Annotated[_SessionResources | None, UntrackedValue, PrivateStateAttr]109    ]110111112@dataclass(frozen=True)113class CommandExecutionResult:114    """Structured result from command execution."""115116    output: str117    exit_code: int | None118    timed_out: bool119    truncated_by_lines: bool120    truncated_by_bytes: bool121    total_lines: int122    total_bytes: int123124125class ShellSession:126    """Persistent shell session that supports sequential command execution."""127128    def __init__(129        self,130        workspace: Path,131        policy: BaseExecutionPolicy,132        command: tuple[str, ...],133        environment: Mapping[str, str],134    ) -> None:135        self._workspace = workspace136        self._policy = policy137        self._command = command138        self._environment = dict(environment)139        self._process: subprocess.Popen[str] | None = None140        self._stdin: Any = None141        self._queue: queue.Queue[tuple[str, str | None]] = queue.Queue()142        self._lock = threading.Lock()143        self._stdout_thread: threading.Thread | None = None144        self._stderr_thread: threading.Thread | None = None145        self._terminated = False146147    def start(self) -> None:148        """Start the shell subprocess and reader threads.149150        Raises:151            RuntimeError: If the shell session pipes cannot be initialized.152        """153        if self._process and self._process.poll() is None:154            return155156        self._process = self._policy.spawn(157            workspace=self._workspace,158            env=self._environment,159            command=self._command,160        )161        if (162            self._process.stdin is None163            or self._process.stdout is None164            or self._process.stderr is None165        ):166            msg = "Failed to initialize shell session pipes."167            raise RuntimeError(msg)168169        self._stdin = self._process.stdin170        self._terminated = False171        self._queue = queue.Queue()172173        self._stdout_thread = threading.Thread(174            target=self._enqueue_stream,175            args=(self._process.stdout, "stdout"),176            daemon=True,177        )178        self._stderr_thread = threading.Thread(179            target=self._enqueue_stream,180            args=(self._process.stderr, "stderr"),181            daemon=True,182        )183        self._stdout_thread.start()184        self._stderr_thread.start()185186    def restart(self) -> None:187        """Restart the shell process."""188        self.stop(self._policy.termination_timeout)189        self.start()190191    def stop(self, timeout: float) -> None:192        """Stop the shell subprocess."""193        if not self._process:194            return195196        if self._process.poll() is None and not self._terminated:197            try:198                self._stdin.write("exit\n")199                self._stdin.flush()200            except (BrokenPipeError, OSError):201                LOGGER.debug(202                    "Failed to write exit command; terminating shell session.",203                    exc_info=True,204                )205206        try:207            if self._process.wait(timeout=timeout) is None:208                self._kill_process()209        except subprocess.TimeoutExpired:210            self._kill_process()211        finally:212            self._terminated = True213            with contextlib.suppress(Exception):214                self._stdin.close()215            self._process = None216217    def execute(self, command: str, *, timeout: float) -> CommandExecutionResult:218        """Execute a command in the persistent shell."""219        if not self._process or self._process.poll() is not None:220            msg = "Shell session is not running."221            raise RuntimeError(msg)222223        marker = f"{_DONE_MARKER_PREFIX}{uuid.uuid4().hex}"224        deadline = time.monotonic() + timeout225226        with self._lock:227            self._drain_queue()228            payload = command if command.endswith("\n") else f"{command}\n"229            try:230                self._stdin.write(payload)231                self._stdin.write(f"printf '{marker} %s\\n' $?\n")232                self._stdin.flush()233            except (BrokenPipeError, OSError):234                # The shell exited before we could write the marker command.235                # This happens when commands like 'exit 1' terminate the shell.236                return self._collect_output_after_exit(deadline)237238            return self._collect_output(marker, deadline, timeout)239240    def _collect_output(241        self,242        marker: str,243        deadline: float,244        timeout: float,245    ) -> CommandExecutionResult:246        collected: list[str] = []247        total_lines = 0248        total_bytes = 0249        truncated_by_lines = False250        truncated_by_bytes = False251        exit_code: int | None = None252        timed_out = False253254        while True:255            remaining = deadline - time.monotonic()256            if remaining <= 0:257                timed_out = True258                break259            try:260                source, data = self._queue.get(timeout=remaining)261            except queue.Empty:262                timed_out = True263                break264265            if data is None:266                continue267268            if source == "stdout" and data.startswith(marker):269                _, _, status = data.partition(" ")270                exit_code = self._safe_int(status.strip())271                # Drain any remaining stderr that may have arrived concurrently.272                # The stderr reader thread runs independently, so output might273                # still be in flight when the stdout marker arrives.274                self._drain_remaining_stderr(collected, deadline)275                break276277            total_lines += 1278            encoded = data.encode("utf-8", "replace")279            total_bytes += len(encoded)280281            if total_lines > self._policy.max_output_lines:282                truncated_by_lines = True283                continue284285            if (286                self._policy.max_output_bytes is not None287                and total_bytes > self._policy.max_output_bytes288            ):289                truncated_by_bytes = True290                continue291292            if source == "stderr":293                stripped = data.rstrip("\n")294                collected.append(f"[stderr] {stripped}")295                if data.endswith("\n"):296                    collected.append("\n")297            else:298                collected.append(data)299300        if timed_out:301            LOGGER.warning(302                "Command timed out after %.2f seconds; restarting shell session.",303                timeout,304            )305            self.restart()306            return CommandExecutionResult(307                output="",308                exit_code=None,309                timed_out=True,310                truncated_by_lines=truncated_by_lines,311                truncated_by_bytes=truncated_by_bytes,312                total_lines=total_lines,313                total_bytes=total_bytes,314            )315316        output = "".join(collected)317        return CommandExecutionResult(318            output=output,319            exit_code=exit_code,320            timed_out=False,321            truncated_by_lines=truncated_by_lines,322            truncated_by_bytes=truncated_by_bytes,323            total_lines=total_lines,324            total_bytes=total_bytes,325        )326327    def _collect_output_after_exit(self, deadline: float) -> CommandExecutionResult:328        """Collect output after the shell exited unexpectedly.329330        Called when a `BrokenPipeError` occurs while writing to stdin, indicating the331        shell process terminated (e.g., due to an 'exit' command).332333        Args:334            deadline: Absolute time by which collection must complete.335336        Returns:337            `CommandExecutionResult` with collected output and the process exit code.338        """339        collected: list[str] = []340        total_lines = 0341        total_bytes = 0342        truncated_by_lines = False343        truncated_by_bytes = False344345        # Give reader threads a brief moment to enqueue any remaining output.346        drain_timeout = 0.1347        drain_deadline = min(time.monotonic() + drain_timeout, deadline)348349        while True:350            remaining = drain_deadline - time.monotonic()351            if remaining <= 0:352                break353            try:354                source, data = self._queue.get(timeout=remaining)355            except queue.Empty:356                break357358            if data is None:359                # EOF marker from a reader thread; continue draining.360                continue361362            total_lines += 1363            encoded = data.encode("utf-8", "replace")364            total_bytes += len(encoded)365366            if total_lines > self._policy.max_output_lines:367                truncated_by_lines = True368                continue369370            if (371                self._policy.max_output_bytes is not None372                and total_bytes > self._policy.max_output_bytes373            ):374                truncated_by_bytes = True375                continue376377            if source == "stderr":378                stripped = data.rstrip("\n")379                collected.append(f"[stderr] {stripped}")380                if data.endswith("\n"):381                    collected.append("\n")382            else:383                collected.append(data)384385        # Get exit code from the terminated process.386        exit_code: int | None = None387        if self._process:388            exit_code = self._process.poll()389390        output = "".join(collected)391        return CommandExecutionResult(392            output=output,393            exit_code=exit_code,394            timed_out=False,395            truncated_by_lines=truncated_by_lines,396            truncated_by_bytes=truncated_by_bytes,397            total_lines=total_lines,398            total_bytes=total_bytes,399        )400401    def _kill_process(self) -> None:402        if not self._process:403            return404405        if hasattr(os, "killpg"):406            try:407                child_pgid = os.getpgid(self._process.pid)408                # Only send a group kill when the child has a dedicated process group.409                # If the child shares our group, killpg would terminate the caller too,410                # so fall through to the direct kill below. That direct kill reaps only411                # the immediate child, so any descendants it spawned may be orphaned.412                # This applies to HostExecutionPolicy(create_process_group=False), the413                # only policy that runs the shell in the caller's process group.414                if child_pgid != os.getpgrp():415                    os.killpg(child_pgid, signal.SIGKILL)416                    return417            except ProcessLookupError:418                # Process already gone; nothing left to kill.419                return420            except OSError:421                # e.g. EPERM while querying or signaling the group. Don't leak the422                # child silently; fall through to a direct kill.423                LOGGER.warning(424                    "Group kill failed; falling back to direct kill.",425                    exc_info=True,426                )427428        try:429            self._process.kill()430        except ProcessLookupError:431            # Process exited between the check above and this kill; nothing to do.432            pass433        except OSError:434            # The fallback kill can hit the same condition (e.g. EPERM) that routed us435            # here. Log rather than let it escape the session shutdown path.436            LOGGER.warning(437                "Direct kill failed.",438                exc_info=True,439            )440441    def _enqueue_stream(self, stream: Any, label: str) -> None:442        for line in iter(stream.readline, ""):443            self._queue.put((label, line))444        self._queue.put((label, None))445446    def _drain_queue(self) -> None:447        while True:448            try:449                self._queue.get_nowait()450            except queue.Empty:451                break452453    def _drain_remaining_stderr(454        self, collected: list[str], deadline: float, drain_timeout: float = 0.05455    ) -> None:456        """Drain any stderr output that arrived concurrently with the done marker.457458        The stdout and stderr reader threads run independently. When a command writes to459        stderr just before exiting, the stderr output may still be in transit when the460        done marker arrives on stdout. This method briefly polls the queue to capture461        such output.462463        Args:464            collected: The list to append collected stderr lines to.465            deadline: The original command deadline (used as an upper bound).466            drain_timeout: Maximum time to wait for additional stderr output.467        """468        drain_deadline = min(time.monotonic() + drain_timeout, deadline)469        while True:470            remaining = drain_deadline - time.monotonic()471            if remaining <= 0:472                break473            try:474                source, data = self._queue.get(timeout=remaining)475            except queue.Empty:476                break477            if data is None or source != "stderr":478                continue479            stripped = data.rstrip("\n")480            collected.append(f"[stderr] {stripped}")481            if data.endswith("\n"):482                collected.append("\n")483484    @staticmethod485    def _safe_int(value: str) -> int | None:486        with contextlib.suppress(ValueError):487            return int(value)488        return None489490491class _ShellToolInput(BaseModel):492    """Input schema for the persistent shell tool."""493494    command: str | None = None495    """The shell command to execute."""496497    restart: bool | None = None498    """Whether to restart the shell session."""499500    runtime: Annotated[Any, SkipJsonSchema()] = None501    """The runtime for the shell tool.502503    Included as a workaround at the moment bc args_schema doesn't work with504    injected ToolRuntime.505    """506507    @model_validator(mode="after")508    def validate_payload(self) -> _ShellToolInput:509        if self.command is None and not self.restart:510            msg = "Shell tool requires either 'command' or 'restart'."511            raise ValueError(msg)512        if self.command is not None and self.restart:513            msg = "Specify only one of 'command' or 'restart'."514            raise ValueError(msg)515        return self516517518class ShellToolMiddleware(AgentMiddleware[ShellToolState[ResponseT], ContextT, ResponseT]):519    """Middleware that registers a persistent shell tool for agents.520521    The middleware exposes a single long-lived shell session. Use the execution policy522    to match your deployment's security posture:523524    * `HostExecutionPolicy`  full host access; best for trusted environments where the525        agent already runs inside a container or VM that provides isolation.526    * `CodexSandboxExecutionPolicy`  reuses the Codex CLI sandbox for additional527        syscall/filesystem restrictions when the CLI is available.528    * `DockerExecutionPolicy`  launches a separate Docker container for each agent run,529        providing harder isolation, optional read-only root filesystems, and user530        remapping.531532    When no policy is provided the middleware defaults to `HostExecutionPolicy`.533    """534535    state_schema = ShellToolState  # type: ignore[assignment]536537    def __init__(538        self,539        workspace_root: str | Path | None = None,540        *,541        startup_commands: tuple[str, ...] | list[str] | str | None = None,542        shutdown_commands: tuple[str, ...] | list[str] | str | None = None,543        execution_policy: BaseExecutionPolicy | None = None,544        redaction_rules: tuple[RedactionRule, ...] | list[RedactionRule] | None = None,545        tool_description: str | None = None,546        tool_name: str = SHELL_TOOL_NAME,547        shell_command: Sequence[str] | str | None = None,548        env: Mapping[str, Any] | None = None,549    ) -> None:550        """Initialize an instance of `ShellToolMiddleware`.551552        Args:553            workspace_root: Base directory for the shell session.554555                If omitted, a temporary directory is created when the agent starts and556                removed when it ends.557            startup_commands: Optional commands executed sequentially after the session558                starts.559            shutdown_commands: Optional commands executed before the session shuts down.560            execution_policy: Execution policy controlling timeouts, output limits, and561                resource configuration.562563                Defaults to `HostExecutionPolicy` for native execution.564            redaction_rules: Optional redaction rules to sanitize command output before565                returning it to the model.566567                !!! warning568                    Redaction rules are applied post execution and do not prevent569                    exfiltration of secrets or sensitive data when using570                    `HostExecutionPolicy`.571572            tool_description: Optional override for the registered shell tool573                description.574            tool_name: Name for the registered shell tool.575576                Defaults to `"shell"`.577            shell_command: Optional shell executable (string) or argument sequence used578                to launch the persistent session.579580                Defaults to an implementation-defined bash command.581            env: Optional environment variables to supply to the shell session.582583                Values are coerced to strings before command execution. If omitted, the584                session inherits the parent process environment.585        """586        super().__init__()587        self._workspace_root = Path(workspace_root) if workspace_root else None588        self._tool_name = tool_name589        self._shell_command = self._normalize_shell_command(shell_command)590        self._environment = self._normalize_env(env)591        if execution_policy is not None:592            self._execution_policy = execution_policy593        else:594            self._execution_policy = HostExecutionPolicy()595        rules = redaction_rules or ()596        self._redaction_rules: tuple[ResolvedRedactionRule, ...] = tuple(597            rule.resolve() for rule in rules598        )599        self._startup_commands = self._normalize_commands(startup_commands)600        self._shutdown_commands = self._normalize_commands(shutdown_commands)601602        # Create a proper tool that executes directly (no interception needed)603        description = tool_description or DEFAULT_TOOL_DESCRIPTION604605        @tool(self._tool_name, args_schema=_ShellToolInput, description=description)606        def shell_tool(607            *,608            runtime: ToolRuntime[None, ShellToolState],609            command: str | None = None,610            restart: bool = False,611        ) -> ToolMessage | str:612            resources = self._get_or_create_resources(runtime.state)613            return self._run_shell_tool(614                resources,615                {"command": command, "restart": restart},616                tool_call_id=runtime.tool_call_id,617            )618619        self._shell_tool = shell_tool620        self.tools = [self._shell_tool]621622    @staticmethod623    def _normalize_commands(624        commands: tuple[str, ...] | list[str] | str | None,625    ) -> tuple[str, ...]:626        if commands is None:627            return ()628        if isinstance(commands, str):629            return (commands,)630        return tuple(commands)631632    @staticmethod633    def _normalize_shell_command(634        shell_command: Sequence[str] | str | None,635    ) -> tuple[str, ...]:636        if shell_command is None:637            return ("/bin/bash",)638        normalized = (shell_command,) if isinstance(shell_command, str) else tuple(shell_command)639        if not normalized:640            msg = "Shell command must contain at least one argument."641            raise ValueError(msg)642        return normalized643644    @staticmethod645    def _normalize_env(env: Mapping[str, Any] | None) -> dict[str, str] | None:646        if env is None:647            return None648        normalized: dict[str, str] = {}649        for key, value in env.items():650            if not isinstance(key, str):651                msg = "Environment variable names must be strings."  # type: ignore[unreachable]652                raise TypeError(msg)653            normalized[key] = str(value)654        return normalized655656    @override657    def before_agent(658        self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]659    ) -> dict[str, Any] | None:660        """Start the shell session and run startup commands.661662        Args:663            state: The current agent state.664            runtime: The runtime context.665666        Returns:667            Shell session resources to be stored in the agent state.668        """669        resources = self._get_or_create_resources(state)670        return {"shell_session_resources": resources}671672    async def abefore_agent(673        self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]674    ) -> dict[str, Any] | None:675        """Async start the shell session and run startup commands.676677        Args:678            state: The current agent state.679            runtime: The runtime context.680681        Returns:682            Shell session resources to be stored in the agent state.683        """684        return await run_in_executor(None, self.before_agent, state, runtime)685686    @override687    def after_agent(self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]) -> None:688        """Run shutdown commands and release resources when an agent completes."""689        resources = state.get("shell_session_resources")690        if not isinstance(resources, _SessionResources):691            # Resources were never created, nothing to clean up692            return693        try:694            self._run_shutdown_commands(resources.session)695        finally:696            resources.finalizer()697698    async def aafter_agent(699        self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]700    ) -> None:701        """Async run shutdown commands and release resources when an agent completes."""702        return self.after_agent(state, runtime)703704    def _get_or_create_resources(self, state: ShellToolState[ResponseT]) -> _SessionResources:705        """Get existing resources from state or create new ones if they don't exist.706707        This method enables resumability by checking if resources already exist in the state708        (e.g., after an interrupt), and only creating new resources if they're not present.709710        Args:711            state: The agent state which may contain shell session resources.712713        Returns:714            Session resources, either retrieved from state or newly created.715        """716        resources = state.get("shell_session_resources")717        if isinstance(resources, _SessionResources):718            return resources719720        new_resources = self._create_resources()721        # Cast needed to make state dict-like for mutation722        cast("dict[str, Any]", state)["shell_session_resources"] = new_resources723        return new_resources724725    def _create_resources(self) -> _SessionResources:726        workspace = self._workspace_root727        tempdir: tempfile.TemporaryDirectory[str] | None = None728        if workspace is None:729            tempdir = tempfile.TemporaryDirectory(prefix=SHELL_TEMP_PREFIX)730            workspace_path = Path(tempdir.name)731        else:732            workspace_path = workspace733            workspace_path.mkdir(parents=True, exist_ok=True)734735        session = ShellSession(736            workspace_path,737            self._execution_policy,738            self._shell_command,739            self._environment or {},740        )741        try:742            session.start()743            LOGGER.info("Started shell session in %s", workspace_path)744            self._run_startup_commands(session)745        except BaseException:746            LOGGER.exception("Starting shell session failed; cleaning up resources.")747            session.stop(self._execution_policy.termination_timeout)748            if tempdir is not None:749                tempdir.cleanup()750            raise751752        return _SessionResources(session=session, tempdir=tempdir, policy=self._execution_policy)753754    def _run_startup_commands(self, session: ShellSession) -> None:755        if not self._startup_commands:756            return757        for command in self._startup_commands:758            result = session.execute(command, timeout=self._execution_policy.startup_timeout)759            if result.timed_out or (result.exit_code not in {0, None}):760                msg = f"Startup command '{command}' failed with exit code {result.exit_code}"761                raise RuntimeError(msg)762763    def _run_shutdown_commands(self, session: ShellSession) -> None:764        if not self._shutdown_commands:765            return766        for command in self._shutdown_commands:767            try:768                result = session.execute(command, timeout=self._execution_policy.command_timeout)769                if result.timed_out:770                    LOGGER.warning("Shutdown command '%s' timed out.", command)771                elif result.exit_code not in {0, None}:772                    LOGGER.warning(773                        "Shutdown command '%s' exited with %s.", command, result.exit_code774                    )775            except (RuntimeError, ToolException, OSError) as exc:776                LOGGER.warning(777                    "Failed to run shutdown command '%s': %s", command, exc, exc_info=True778                )779780    def _apply_redactions(self, content: str) -> tuple[str, dict[str, list[PIIMatch]]]:781        """Apply configured redaction rules to command output."""782        matches_by_type: dict[str, list[PIIMatch]] = {}783        updated = content784        for rule in self._redaction_rules:785            updated, matches = rule.apply(updated)786            if matches:787                matches_by_type.setdefault(rule.pii_type, []).extend(matches)788        return updated, matches_by_type789790    @overload791    def _run_shell_tool(792        self,793        resources: _SessionResources,794        payload: dict[str, Any],795        *,796        tool_call_id: str,797    ) -> ToolMessage: ...798799    @overload800    def _run_shell_tool(801        self,802        resources: _SessionResources,803        payload: dict[str, Any],804        *,805        tool_call_id: None,806    ) -> str: ...807808    def _run_shell_tool(809        self,810        resources: _SessionResources,811        payload: dict[str, Any],812        *,813        tool_call_id: str | None,814    ) -> ToolMessage | str:815        session = resources.session816817        if payload.get("restart"):818            LOGGER.info("Restarting shell session on request.")819            try:820                session.restart()821                self._run_startup_commands(session)822            except BaseException as err:823                LOGGER.exception("Restarting shell session failed; session remains unavailable.")824                msg = "Failed to restart shell session."825                raise ToolException(msg) from err826            message = "Shell session restarted."827            return self._format_tool_message(message, tool_call_id, status="success")828829        command = payload.get("command")830        if not command or not isinstance(command, str):831            msg = "Shell tool expects a 'command' string when restart is not requested."832            raise ToolException(msg)833834        LOGGER.info("Executing shell command: %s", command)835        result = session.execute(command, timeout=self._execution_policy.command_timeout)836837        if result.timed_out:838            timeout_seconds = self._execution_policy.command_timeout839            message = f"Error: Command timed out after {timeout_seconds:.1f} seconds."840            return self._format_tool_message(841                message,842                tool_call_id,843                status="error",844                artifact={845                    "timed_out": True,846                    "exit_code": None,847                },848            )849850        try:851            sanitized_output, matches = self._apply_redactions(result.output)852        except PIIDetectionError as error:853            LOGGER.warning("Blocking command output due to detected %s.", error.pii_type)854            message = f"Output blocked: detected {error.pii_type}."855            return self._format_tool_message(856                message,857                tool_call_id,858                status="error",859                artifact={860                    "timed_out": False,861                    "exit_code": result.exit_code,862                    "matches": {error.pii_type: error.matches},863                },864            )865866        sanitized_output = sanitized_output or "<no output>"867        if result.truncated_by_lines:868            sanitized_output = (869                f"{sanitized_output.rstrip()}\n\n"870                f"... Output truncated at {self._execution_policy.max_output_lines} lines "871                f"(observed {result.total_lines})."872            )873        if result.truncated_by_bytes and self._execution_policy.max_output_bytes is not None:874            sanitized_output = (875                f"{sanitized_output.rstrip()}\n\n"876                f"... Output truncated at {self._execution_policy.max_output_bytes} bytes "877                f"(observed {result.total_bytes})."878            )879880        if result.exit_code not in {0, None}:881            sanitized_output = f"{sanitized_output.rstrip()}\n\nExit code: {result.exit_code}"882            final_status: Literal["success", "error"] = "error"883        else:884            final_status = "success"885886        artifact = {887            "timed_out": False,888            "exit_code": result.exit_code,889            "truncated_by_lines": result.truncated_by_lines,890            "truncated_by_bytes": result.truncated_by_bytes,891            "total_lines": result.total_lines,892            "total_bytes": result.total_bytes,893            "redaction_matches": matches,894        }895896        return self._format_tool_message(897            sanitized_output,898            tool_call_id,899            status=final_status,900            artifact=artifact,901        )902903    @overload904    def _format_tool_message(905        self,906        content: str,907        tool_call_id: str,908        *,909        status: Literal["success", "error"],910        artifact: dict[str, Any] | None = None,911    ) -> ToolMessage: ...912913    @overload914    def _format_tool_message(915        self,916        content: str,917        tool_call_id: None,918        *,919        status: Literal["success", "error"],920        artifact: dict[str, Any] | None = None,921    ) -> str: ...922923    def _format_tool_message(924        self,925        content: str,926        tool_call_id: str | None,927        *,928        status: Literal["success", "error"],929        artifact: dict[str, Any] | None = None,930    ) -> ToolMessage | str:931        artifact = artifact or {}932        if tool_call_id is None:933            return content934        return ToolMessage(935            content=content,936            tool_call_id=tool_call_id,937            name=self._tool_name,938            status=status,939            artifact=artifact,940        )941942943__all__ = [944    "CodexSandboxExecutionPolicy",945    "DockerExecutionPolicy",946    "HostExecutionPolicy",947    "RedactionRule",948    "ShellToolMiddleware",949]

Code quality findings 14

Use concurrent.futures for easier thread management
threading-thread
self._stdout_thread = threading.Thread(
Use concurrent.futures for easier thread management
threading-thread
self._stderr_thread = threading.Thread(
Ensure try blocks have corresponding except or finally blocks
try-without-except
try:
Ensure functions have docstrings for documentation
missing-docstring
def validate_payload(self) -> _ShellToolInput:
Ensure functions have docstrings for documentation
missing-docstring
def shell_tool(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(commands, str):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
normalized = (shell_command,) if isinstance(shell_command, str) else tuple(shell_command)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(key, str):
Ensure functions have docstrings for documentation
missing-docstring
def before_agent(
Ensure functions have docstrings for documentation
missing-docstring
async def abefore_agent(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(resources, _SessionResources):
Ensure functions have docstrings for documentation
missing-docstring
async def aafter_agent(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(resources, _SessionResources):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not command or not isinstance(command, str):

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.