Use concurrent.futures for easier thread management
self._stdout_thread = threading.Thread(
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]
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.