Files
nanobot/nanobot/agent/tools/shell.py
T

806 lines
32 KiB
Python
Raw Normal View History

2026-02-01 07:36:42 +00:00
"""Shell execution tool."""
from __future__ import annotations
2026-02-01 07:36:42 +00:00
import asyncio
import os
2026-02-02 21:14:29 +03:00
import re
import shutil
import sys
from contextlib import suppress
2026-05-20 12:51:26 +08:00
from dataclasses import dataclass
2026-07-01 17:45:59 +08:00
from pathlib import Path, PureWindowsPath
2026-02-01 07:36:42 +00:00
from typing import Any
from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key
2026-05-20 12:51:26 +08:00
from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER,
2026-05-20 12:51:26 +08:00
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_YIELD_MS,
MAX_OUTPUT_CHARS,
MAX_YIELD_MS,
clamp_session_int,
format_session_poll,
)
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config.paths import get_media_dir
2026-06-13 01:48:45 +08:00
from nanobot.config_base import Base
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
from nanobot.security.workspace_policy import is_path_within
2026-02-01 07:36:42 +00:00
_IS_WINDOWS = sys.platform == "win32"
2026-02-01 07:36:42 +00:00
def _reap_pid(pid: int) -> None:
"""Best-effort ``waitpid`` to reap a child and prevent zombies.
Call this after killing or after normal completion of any subprocess
as a safety net — asyncio's child-watcher *should* have reaped it,
but in containers / edge-cases it sometimes doesn't.
Uses ``os`` capability checks rather than ``_IS_WINDOWS`` so this is
safe when tests patch the platform flag while still running on Windows
(``os.waitpid`` / ``os.WNOHANG`` do not exist there).
"""
waitpid = getattr(os, "waitpid", None)
wnohang = getattr(os, "WNOHANG", None)
if waitpid is None or wnohang is None:
return
try:
waitpid(pid, wnohang)
except (ProcessLookupError, ChildProcessError):
# Already reaped, or not our child — both are fine.
pass
except OSError as exc:
logger.debug("_reap_pid({}): {}", pid, exc)
# Policy note appended to recoverable workspace-boundary guard errors.
_WORKSPACE_BOUNDARY_NOTE = (
"\n\nNote: this is a hard policy boundary, not a transient failure. "
"Do NOT retry with shell tricks (symlinks, base64 piping, alternative "
"tools, working_dir overrides). If the user genuinely needs this "
"resource, tell them you cannot reach it under the current "
"restrict_to_workspace policy and ask how to proceed."
)
class ExecToolConfig(Base):
"""Shell exec tool configuration."""
enable: bool = True
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
2026-06-10 15:50:24 +08:00
path_prepend: str = ""
path_append: str = ""
sandbox: str = ""
allowed_env_keys: list[str] = Field(default_factory=list)
allow_patterns: list[str] = Field(default_factory=list)
deny_patterns: list[str] = Field(default_factory=list)
2026-05-20 12:51:26 +08:00
@dataclass(slots=True)
class _PreparedCommand:
command: str
cwd: str
env: dict[str, str]
timeout: int | None
2026-05-20 12:51:26 +08:00
shell_program: str | None
login: bool
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
2026-05-20 12:51:26 +08:00
cmd=StringSchema("Compatibility alias for command"),
working_dir=StringSchema("Optional working directory for the command"),
2026-05-20 12:51:26 +08:00
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema(
60,
description=(
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
),
minimum=1,
maximum=600,
),
2026-05-20 12:51:26 +08:00
shell=StringSchema(
(
2026-07-01 18:30:16 +08:00
"Override the Windows shell only when needed. Omit to use "
"PowerShell by default (pwsh when available, else powershell). "
"Pass 'cmd' only for cmd.exe syntax or cmd built-ins."
if _IS_WINDOWS
2026-07-01 18:30:16 +08:00
else "Override the Unix shell only when needed. Omit to use "
"bash by default. Pass 'sh' for POSIX sh or 'zsh' for "
"zsh-specific syntax."
),
2026-05-20 12:51:26 +08:00
nullable=True,
),
login=BooleanSchema(
description="Whether to run bash/zsh with login shell semantics (default false).",
default=False,
2026-05-20 12:51:26 +08:00
nullable=True,
),
yield_time_ms=IntegerSchema(
description=(
"Optional milliseconds to wait before returning output. "
"When set, a still-running command returns a session_id that "
"can be polled or written to with write_stdin. Omit this field "
"to keep one-shot exec behavior."
),
minimum=0,
maximum=MAX_YIELD_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
description=(
"Maximum output characters to return when yield_time_ms is used "
"(default 10000, max 50000)."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
max_output_tokens=IntegerSchema(
description=(
"Compatibility alias for max_output_chars. The current runtime "
"uses a character budget."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
)
)
2026-02-01 07:36:42 +00:00
class ExecTool(Tool):
"""Tool to execute shell commands."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.exec
return cls(
working_dir=ctx.workspace,
timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox,
2026-06-10 15:50:24 +08:00
path_prepend=cfg.path_prepend,
path_append=cfg.path_append,
allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns,
deny_patterns=cfg.deny_patterns,
)
2026-02-02 21:14:29 +03:00
def __init__(
self,
timeout: int = 60,
working_dir: str | None = None,
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "",
2026-06-10 15:50:24 +08:00
path_prepend: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
2026-05-20 12:51:26 +08:00
session_manager: Any | None = None,
2026-02-02 21:14:29 +03:00
):
2026-02-01 07:36:42 +00:00
self.timeout = timeout
self.working_dir = working_dir
self.sandbox = sandbox
self.deny_patterns = (deny_patterns or []) + [
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
r"\brmdir\s+/s\b", # rmdir /s
r"(?:^|[;&|]\s*)format(?!=)\b", # format (as standalone command only)
r"\b(mkfs|diskpart)\b", # disk operations
r"\bdd\s+if=", # dd
r">\s*/dev/sd", # write to disk
r"\b(shutdown|reboot|poweroff)\b", # system power
r":\(\)\s*\{.*\};\s*:", # fork bomb
# Block writes to nanobot internal state files (#2989).
# history.jsonl / .dream_cursor are managed by append_history();
# direct writes corrupt the cursor format and crash /dream.
r">>?\s*\S*(?:history\.jsonl|\.dream_cursor)", # > / >> redirect
r"\btee\b[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # tee / tee -a
r"\b(?:cp|mv)\b(?:\s+[^\s|;&<>]+)+\s+\S*(?:history\.jsonl|\.dream_cursor)", # cp/mv target
r"\bdd\b[^|;&<>]*\bof=\S*(?:history\.jsonl|\.dream_cursor)", # dd of=
r"\bsed\s+-i[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # sed -i
2026-02-02 21:14:29 +03:00
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
if allow_local_preview_access is not None:
webui_allow_local_service_access = allow_local_preview_access
self.webui_allow_local_service_access = webui_allow_local_service_access
2026-06-10 15:50:24 +08:00
self.path_prepend = path_prepend
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
2026-05-20 12:51:26 +08:00
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
2026-02-01 07:36:42 +00:00
@property
def name(self) -> str:
return "exec"
_MAX_TIMEOUT = 600
_MAX_OUTPUT = 10_000
# Kernel device files safe as stdio redirect targets (#3599).
_BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/random",
"/dev/urandom",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
})
2026-02-01 07:36:42 +00:00
@property
def description(self) -> str:
platform_note = (
"On Windows, use PowerShell syntax by default; pass shell='cmd' "
"only for cmd-specific commands. "
if _IS_WINDOWS
else "On Unix, commands run through bash by default; pass shell='sh' "
"or shell='zsh' when needed. "
)
return (
"Execute a shell command and return its output. "
2026-05-21 14:28:39 +08:00
"Use this for tests, builds, package commands, git commands, and "
"other process execution. Prefer read_file/find_files/grep for "
"inspection and apply_patch/write_file/edit_file for file changes "
"instead of cat, shell find/grep, echo, or sed. "
"Use -y or --yes flags to avoid interactive prompts. "
f"{platform_note}"
2026-05-20 12:51:26 +08:00
"For long-running or interactive commands, pass yield_time_ms; "
"if the command keeps running, exec returns a session_id that can "
"be polled or written to with write_stdin. Output is truncated at "
"10 000 chars; timeout defaults to 60s."
)
2026-02-01 07:36:42 +00:00
@property
def exclusive(self) -> bool:
return True
async def execute(
2026-05-20 12:51:26 +08:00
self, command: str | None = None, cmd: str | None = None,
working_dir: str | None = None, workdir: str | None = None,
timeout: int | None = None, shell: str | None = None,
login: bool | None = None, yield_time_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any,
) -> str:
2026-05-20 12:51:26 +08:00
command = command or cmd
working_dir = working_dir or workdir
if not command:
return ToolResult.error("Error: Missing command. Provide command or cmd.")
2026-05-20 12:51:26 +08:00
if max_output_chars is None:
max_output_chars = max_output_tokens
prepared = self._prepare_command(command, working_dir, timeout, shell, login)
if isinstance(prepared, str):
return prepared
if yield_time_ms is not None:
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
process: asyncio.subprocess.Process | None = None
2026-05-20 12:51:26 +08:00
try:
process = await self._spawn(
prepared.command,
prepared.cwd,
prepared.env,
prepared.shell_program,
prepared.login,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=prepared.timeout,
)
except asyncio.TimeoutError:
await self._kill_process(process)
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
2026-05-20 12:51:26 +08:00
except asyncio.CancelledError:
await self._kill_process(process)
raise
# Safety-net reap: asyncio *should* have reaped the child via
# communicate(), but in containers the child-watcher sometimes
# misses it, leaving a zombie.
_reap_pid(process.pid)
2026-05-20 12:51:26 +08:00
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
2026-05-20 12:51:26 +08:00
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
2026-05-20 12:51:26 +08:00
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
if len(result) > max_len:
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
return result
except Exception as e:
# Kill and reap the child if it was spawned but an unexpected
# error prevented communicate() from completing.
if process is not None:
await self._kill_process(process)
return ToolResult.error(f"Error executing command: {str(e)}")
2026-05-20 12:51:26 +08:00
async def _execute_session(
self,
prepared: _PreparedCommand,
yield_time_ms: int | None,
max_output_chars: int | None,
) -> str:
try:
session_id, poll = await self._session_manager.start(
command=prepared.command,
cwd=prepared.cwd,
env=prepared.env,
timeout=prepared.timeout,
shell_program=prepared.shell_program,
login=prepared.login,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
owner_session_key=current_request_session_key(),
2026-05-20 12:51:26 +08:00
max_output_chars=clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
1000,
MAX_OUTPUT_CHARS,
),
)
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
2026-05-20 12:51:26 +08:00
except Exception as exc:
return ToolResult.error(f"Error executing command: {exc}")
2026-05-20 12:51:26 +08:00
def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit).
A per-call timeout supplied by the model stays capped at _MAX_TIMEOUT so
the LLM cannot request unbounded execution. The config-level default
(self.timeout) may exceed that cap, and 0 disables the limit entirely
for trusted long-running tasks (#3595).
"""
if timeout:
return min(timeout, self._MAX_TIMEOUT)
if self.timeout and self.timeout > 0:
return self.timeout
return None
2026-05-20 12:51:26 +08:00
def _prepare_command(
self,
command: str,
working_dir: str | None = None,
timeout: int | None = None,
shell: str | None = None,
login: bool | None = None,
) -> _PreparedCommand | str:
access = current_tool_workspace(
self.working_dir,
restrict_to_workspace=self.restrict_to_workspace,
sandbox_restricts_workspace=bool(self.sandbox),
)
workspace_root = str(access.project_path) if access.project_path is not None else self.working_dir
cwd = working_dir or workspace_root or os.getcwd()
# Prevent an LLM-supplied working_dir from escaping the configured
# workspace when restrict_to_workspace is enabled (#2826). Without
# this, a caller can pass working_dir="/etc" and then all absolute
# paths under /etc would pass the _guard_command check that anchors
# on cwd.
if access.restrict_to_workspace and workspace_root:
try:
requested = Path(cwd).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve()
except Exception:
return ToolResult.error(
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if not is_path_within(requested, resolved_root):
return ToolResult.error(
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
guard_error = self._guard_command(
command,
cwd,
restrict_to_workspace=access.restrict_to_workspace,
workspace_root=workspace_root,
)
2026-02-02 21:14:29 +03:00
if guard_error:
return guard_error
if self.sandbox:
if _IS_WINDOWS:
logger.warning(
"Sandbox '{}' is not supported on Windows; running unsandboxed",
self.sandbox,
)
else:
workspace = workspace_root or cwd
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
effective_timeout = self._resolve_timeout(timeout)
env = self._build_env()
2026-06-10 15:50:24 +08:00
if self.path_prepend or self.path_append:
if _IS_WINDOWS:
2026-06-10 15:50:24 +08:00
env["PATH"] = self._compose_path(env.get("PATH", ""))
else:
2026-06-10 15:50:24 +08:00
command = self._wrap_path_export(command, env)
2026-05-20 12:51:26 +08:00
shell_program, shell_error = self._resolve_shell(shell)
if shell_error:
return shell_error
2026-05-20 12:51:26 +08:00
return _PreparedCommand(
command=command,
cwd=cwd,
env=env,
timeout=effective_timeout,
shell_program=shell_program,
login=False if login is None else login,
2026-05-20 12:51:26 +08:00
)
2026-02-02 21:14:29 +03:00
2026-06-10 15:50:24 +08:00
def _compose_path(self, current_path: str) -> str:
parts = []
if self.path_prepend:
parts.append(self.path_prepend)
if current_path:
parts.append(current_path)
if self.path_append:
parts.append(self.path_append)
return os.pathsep.join(parts)
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
segments = []
if self.path_prepend:
env["NANOBOT_PATH_PREPEND"] = self.path_prepend
segments.append("$NANOBOT_PATH_PREPEND")
segments.append("$PATH")
if self.path_append:
env["NANOBOT_PATH_APPEND"] = self.path_append
segments.append("$NANOBOT_PATH_APPEND")
path_expr = os.pathsep.join(segments)
return f'export PATH="{path_expr}"; {command}'
@staticmethod
async def _spawn(
command: str, cwd: str, env: dict[str, str],
2026-05-20 12:51:26 +08:00
shell_program: str | None = None,
login: bool = False,
*,
stdin: int = asyncio.subprocess.DEVNULL,
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
2026-06-27 03:48:29 +08:00
# Default to PowerShell so single-line and multi-line commands
# share the same shell semantics. cmd.exe is reachable via the
# explicit shell="cmd" parameter (see _resolve_shell).
2026-07-01 17:45:59 +08:00
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
2026-06-27 03:48:29 +08:00
program = shell_program or default_program
2026-07-01 17:45:59 +08:00
program_name = PureWindowsPath(program).name.lower()
2026-06-27 03:48:29 +08:00
if program_name in ("cmd", "cmd.exe"):
2026-07-05 17:34:18 +08:00
cmd_env = {**env, "COMSPEC": program}
return await asyncio.create_subprocess_shell(
command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
2026-07-05 17:34:18 +08:00
env=cmd_env,
)
2026-07-05 17:34:18 +08:00
command = ExecTool._normalize_powershell_command(command)
command = (
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
"$OutputEncoding = [Console]::OutputEncoding\n"
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
f"{command}\n"
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
)
2026-06-27 03:48:29 +08:00
return await asyncio.create_subprocess_exec(
2026-07-01 17:45:59 +08:00
program, "-NoProfile", "-NonInteractive", "-Command", command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
2026-05-20 12:51:26 +08:00
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
2026-05-21 16:06:52 +08:00
shell_name = Path(shell_program).name.lower()
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
2026-05-20 12:51:26 +08:00
args.append("-l")
args.extend(["-c", command])
return await asyncio.create_subprocess_exec(
2026-05-20 12:51:26 +08:00
*args,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
2026-07-05 17:34:18 +08:00
@staticmethod
def _normalize_powershell_command(command: str) -> str:
stripped = command.lstrip()
if not stripped or stripped[0] not in {"'", '"'}:
return command
quote = stripped[0]
end = stripped.find(quote, 1)
if end == -1 or end + 1 >= len(stripped) or not stripped[end + 1].isspace():
return command
executable = stripped[1:end]
looks_like_windows_executable = (
bool(re.match(r"^[A-Za-z]:[\\/]", executable))
or executable.startswith(r"\\")
or executable.lower().endswith((".exe", ".cmd", ".bat", ".ps1"))
)
if not looks_like_windows_executable:
return command
leading = command[: len(command) - len(stripped)]
return f"{leading}& {stripped}"
2026-05-20 12:51:26 +08:00
@staticmethod
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
if not shell:
return None, None
if "\0" in shell or "\n" in shell or "\r" in shell:
return None, ToolResult.error("Error: shell contains invalid characters")
2026-06-27 03:48:29 +08:00
if _IS_WINDOWS:
win_allowed = {"powershell", "powershell.exe", "pwsh", "pwsh.exe", "cmd", "cmd.exe"}
path = Path(shell).expanduser()
if path.is_absolute():
name = path.name.lower()
if name not in win_allowed:
return None, ToolResult.error(
f"Error: unsupported shell {shell!r}. "
"Allowed: powershell, pwsh, cmd"
)
if not path.is_file():
return None, ToolResult.error(f"Error: shell is not found: {shell}")
return str(path), None
if "/" in shell or "\\" in shell:
return None, ToolResult.error("Error: shell must be a shell name or absolute path")
if shell.lower() not in win_allowed:
return None, ToolResult.error(
f"Error: unsupported shell {shell!r}. "
"Allowed: powershell, pwsh, cmd"
)
if shell.lower() in ("cmd", "cmd.exe"):
resolved = os.environ.get("COMSPEC") or shutil.which("cmd") or "cmd"
return resolved, None
resolved = shutil.which(shell) or shell
return resolved, None
2026-05-20 12:51:26 +08:00
allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser()
if path.is_absolute():
if path.name not in allowed:
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
2026-05-20 12:51:26 +08:00
if not path.is_file() or not os.access(path, os.X_OK):
return None, ToolResult.error(f"Error: shell is not executable: {shell}")
2026-05-20 12:51:26 +08:00
return str(path), None
if "/" in shell or "\\" in shell:
return None, ToolResult.error("Error: shell must be a shell name or absolute path")
2026-05-20 12:51:26 +08:00
if shell not in allowed:
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
2026-05-20 12:51:26 +08:00
resolved = shutil.which(shell)
if not resolved:
return None, ToolResult.error(f"Error: shell not found: {shell}")
2026-05-20 12:51:26 +08:00
return resolved, None
@staticmethod
async def _kill_process(process: asyncio.subprocess.Process) -> None:
"""Kill a subprocess and reap it to prevent zombies.
Safe to call when the process has already exited (e.g. generic
exception handlers after a successful ``communicate()``): skips
``kill()`` and only runs the safety-net reap.
"""
if process.returncode is not None:
_reap_pid(process.pid)
return
try:
with suppress(ProcessLookupError):
process.kill()
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(process.wait(), timeout=5.0)
finally:
_reap_pid(process.pid)
def _build_env(self) -> dict[str, str]:
"""Build a minimal environment for subprocess execution.
2026-06-26 10:02:07 +08:00
On Unix, only HOME/LANG/TERM are passed by default. If callers request
``login=True``, bash/zsh may source the user's profile and add PATH or
other variables.
On Windows, ``cmd.exe`` has no login-profile mechanism, so a curated
set of system variables (including PATH) is forwarded. API keys and
other secrets are still excluded.
"""
if _IS_WINDOWS:
sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
env = {
"SYSTEMROOT": sr,
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
"USERPROFILE": os.environ.get("USERPROFILE", ""),
"HOMEDRIVE": os.environ.get("HOMEDRIVE", "C:"),
"HOMEPATH": os.environ.get("HOMEPATH", "\\"),
"TEMP": os.environ.get("TEMP", f"{sr}\\Temp"),
"TMP": os.environ.get("TMP", f"{sr}\\Temp"),
"PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"),
"PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"),
"PYTHONUNBUFFERED": "1",
"APPDATA": os.environ.get("APPDATA", ""),
"LOCALAPPDATA": os.environ.get("LOCALAPPDATA", ""),
"ProgramData": os.environ.get("ProgramData", ""),
"ProgramFiles": os.environ.get("ProgramFiles", ""),
"ProgramFiles(x86)": os.environ.get("ProgramFiles(x86)", ""),
"ProgramW6432": os.environ.get("ProgramW6432", ""),
}
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
home = os.environ.get("HOME", "/tmp")
env = {
"HOME": home,
"LANG": os.environ.get("LANG", "C.UTF-8"),
"TERM": os.environ.get("TERM", "dumb"),
"PYTHONUNBUFFERED": "1",
}
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
def _guard_command(
self,
command: str,
cwd: str,
*,
restrict_to_workspace: bool | None = None,
workspace_root: str | None = None,
) -> str | None:
2026-02-02 21:14:29 +03:00
"""Best-effort safety guard for potentially destructive commands."""
cmd = command.strip()
lower = cmd.lower()
# allow_patterns take priority over deny_patterns so that users can
# exempt specific commands (e.g. "rm -rf" inside a build directory)
# from the hardcoded deny list via configuration.
explicitly_allowed = bool(self.allow_patterns) and any(
re.fullmatch(p, lower) for p in self.allow_patterns
)
if not explicitly_allowed:
for pattern in self.deny_patterns:
if re.search(pattern, lower):
return ToolResult.error("Error: Command blocked by deny pattern filter")
2026-02-02 21:14:29 +03:00
if self.allow_patterns:
return ToolResult.error("Error: Command blocked by allowlist filter (not in allowlist)")
2026-02-02 21:14:29 +03:00
from nanobot.security.network import contains_internal_url
if contains_internal_url(
cmd,
allow_loopback=current_scope_allows_loopback(
enabled=self.webui_allow_local_service_access,
),
):
2026-05-05 16:18:33 +00:00
# The runner turns this marker into a non-retryable security hint.
return ToolResult.error("Error: Command blocked by safety guard (internal/private URL detected)")
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
if should_restrict:
2026-02-02 21:14:29 +03:00
if "..\\" in cmd or "../" in cmd:
return ToolResult.error(
"Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE
)
2026-02-02 21:14:29 +03:00
cwd_path = Path(cwd).resolve()
resolved_workspace = (
Path(workspace_root).expanduser().resolve()
if workspace_root
else None
)
2026-02-02 21:14:29 +03:00
for raw in self._extract_absolute_paths(cmd):
2026-02-02 21:14:29 +03:00
try:
expanded = os.path.expandvars(raw.strip())
# Match against the un-resolved path first. On Linux,
# /dev/stderr is a symlink to /proc/self/fd/2 and
# ``Path.resolve()`` would mask the device-file intent.
if self._is_benign_device_path(expanded):
continue
p = Path(expanded).expanduser().resolve()
2026-02-02 21:14:29 +03:00
except Exception:
continue
if self._is_benign_device_path(str(p)):
continue
media_path = get_media_dir().resolve()
allowed = (
is_path_within(p, cwd_path)
or is_path_within(p, media_path)
)
if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace)
if p.is_absolute() and not allowed:
return ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
2026-02-02 21:14:29 +03:00
return None
@classmethod
def _is_benign_device_path(cls, path: str) -> bool:
"""Return True for kernel device files that should never be workspace-blocked."""
if path in cls._BENIGN_DEVICE_PATHS:
return True
return path.startswith("/dev/fd/")
@staticmethod
def _extract_absolute_paths(command: str) -> list[str]:
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(
2026-05-20 17:16:53 +05:00
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command
)
2026-03-11 15:43:04 +00:00
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths