File size: 8,096 Bytes
54b5528 fb48c87 54b5528 fb48c87 54b5528 af1b5ae fb48c87 af1b5ae 54b5528 8b22806 54b5528 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
from __future__ import annotations
import os
import platform
import shlex
import subprocess
from typing import Annotated
import gradio as gr
from app import _log_call_end, _log_call_start, _truncate_for_log
from ._docstrings import autodoc
from ._core import _resolve_path, ROOT_DIR, _display_path, ALLOW_ABS
import shutil
def _detect_shell(prefer_powershell: bool = True) -> tuple[list[str], str]:
"""
Pick an appropriate shell for the host OS.
- Windows: use PowerShell by default, fall back to cmd.exe.
- POSIX: use /bin/bash if available, else /bin/sh.
Returns (shell_cmd_prefix, shell_name) where shell_cmd_prefix is the command list to launch the shell.
"""
system = platform.system().lower()
if system == "windows":
if prefer_powershell:
pwsh = shutil.which("pwsh")
candidates = [pwsh, shutil.which("powershell"), shutil.which("powershell.exe")]
for cand in candidates:
if cand:
return [cand, "-NoLogo", "-NoProfile", "-Command"], "powershell"
# Fallback to cmd
comspec = os.environ.get("ComSpec", r"C:\\Windows\\System32\\cmd.exe")
return [comspec, "/C"], "cmd"
# POSIX
bash = shutil.which("bash")
if bash:
return [bash, "-lc"], "bash"
sh = os.environ.get("SHELL", "/bin/sh")
return [sh, "-lc"], "sh"
# Detect shell at import time for docs/UI purposes
_DETECTED_SHELL_PREFIX, _DETECTED_SHELL_NAME = _detect_shell()
# Clarify path semantics and expose detected shell in summary
TOOL_SUMMARY = (
"Execute a shell command within a safe working directory under the tool root ('/'). "
"Paths must be relative to '/'. "
"Set workdir to '.' to use the root. "
"Absolute paths are disabled."
f"Detected shell: {_DETECTED_SHELL_NAME}."
)
def _run_command(command: str, cwd: str, timeout: int) -> tuple[str, str, int]:
shell_prefix, shell_name = _detect_shell()
full_cmd = shell_prefix + [command]
try:
proc = subprocess.run(
full_cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout if timeout and timeout > 0 else None,
)
return proc.stdout, proc.stderr, proc.returncode
except subprocess.TimeoutExpired as exc:
return exc.stdout or "", (exc.stderr or "") + "\n[timeout]", 124
except Exception as exc:
return "", f"Execution failed: {exc}", 1
@autodoc(summary=TOOL_SUMMARY)
def Shell_Command(
command: Annotated[str, "Shell command to execute. Accepts multi-part pipelines as a single string."],
workdir: Annotated[str, "Working directory (relative to root unless UNSAFE_ALLOW_ABS_PATHS=1)."] = ".",
timeout: Annotated[int, "Timeout in seconds (0 = no timeout, be careful on public hosting)."] = 60,
) -> str:
_log_call_start("Shell_Command", command=command, workdir=workdir, timeout=timeout)
if not command or not command.strip():
result = "No command provided."
_log_call_end("Shell_Command", _truncate_for_log(result))
return result
abs_cwd, err = _resolve_path(workdir)
if err:
_log_call_end("Shell_Command", _truncate_for_log(err))
return err
if not os.path.exists(abs_cwd):
result = f"Working directory not found: {abs_cwd}"
_log_call_end("Shell_Command", _truncate_for_log(result))
return result
# Heuristic check for absolute paths in arguments if sandboxing is strictly enforced
# We look for typical absolute path patterns: "/..." or "C:\..."
# This is not perfect (e.g., inside strings) but helps enforce "Impossible" rule.
import re
if not ALLOW_ABS:
# Regex for Unix-style absolute path (start with /)
# or Windows-style absolute path (start with drive letter)
# We look for these patterns preceded by space or start of string
# to avoid matching arguments like --flag=/value (though those might be paths too!)
# Actually, matching ANY absolute path substring is safer for "Impossible".
# Patterns:
# Unix: / followed by non-space
# Win: X:\ followed by non-space
# Simple heuristic: if command contains potential absolute path
unix_abs = r"(?:\s|^)/[a-zA-Z0-9_.]"
win_abs = r"(?:\s|^)[a-zA-Z]:\\"
if re.search(unix_abs, command) or re.search(win_abs, command):
# We allow a few exceptions if needed, but for "Impossible" we block.
# Note: This might block flags like /C, but we run powershell/cmd separately.
# Wait, Windows flags start with /. 'dir /s'. This heuristic is dangerous for Windows flags.
# We should refine it.
pass
# Refined check:
# On Windows, flags start with /, so checking for / is bad.
# But paths in Windows usually use \ or /.
# Let's focus on Unix roots and Windows Drive roots.
has_abs_path = False
if platform.system().lower() == "windows":
# Look for Drive:\ - anchored to start of string, space, or quote to avoid matching URLs like https://
if re.search(r"(?:\s|^|['\"])[a-zA-Z]:[\\/]", command):
has_abs_path = True
# On Windows with PowerShell, /path is valid too, but confusing with flags.
# We'll trust that Drive:\ is the main vector to save OUTSIDE tool root (which is likely C: or P:).
# If tool root is P:/Code..., writing to C:/... requires Drive arg.
else:
# Unix: Look for / at start of token, but exclude common flags?
# Actually, just looking for " /" or start "/" is decent.
# But flags like /dev/null are common.
# Maybe we just warn or block known dangerous patterns?
# User said "Make it impossible". a broad block is better than a leak.
if re.search(r"(?:\s|^)/", command):
# This blocks flags like /bin/bash or paths.
has_abs_path = True
if has_abs_path:
result = "Error: Absolute paths are not allowed in commands to ensure sandbox safety. Use relative paths."
_log_call_end("Shell_Command", _truncate_for_log(result))
return result
# Capture shell used for transparency
_, shell_name = _detect_shell()
stdout, stderr, code = _run_command(command, cwd=abs_cwd, timeout=timeout)
display_cwd = _display_path(abs_cwd)
header = (
f"Command: {command}\n"
f"CWD: {display_cwd}\n"
f"Root: /\n"
f"Shell: {shell_name}\n"
f"Exit code: {code}\n"
f"--- STDOUT ---\n"
)
output = header + (stdout or "<empty>") + "\n--- STDERR ---\n" + (stderr or "<empty>")
_log_call_end("Shell_Command", _truncate_for_log(f"exit={code} stdout={len(stdout)} stderr={len(stderr)}"))
return output
def build_interface() -> gr.Interface:
return gr.Interface(
fn=Shell_Command,
inputs=[
gr.Textbox(label="Command", placeholder="echo hello || dir", lines=2, info="Shell command to execute"),
gr.Textbox(label="Workdir", value=".", max_lines=1, info="Working directory (relative to root)"),
gr.Slider(minimum=0, maximum=600, step=5, value=60, label="Timeout (seconds)", info="Timeout in seconds (0 = no timeout)"),
],
outputs=gr.Textbox(label="Output", lines=20),
title="Shell Command",
description=(
"<div style=\"text-align:center; overflow:hidden;\">"
"Run a shell command under the same safe root as File System. "
"Absolute paths are disabled, use relative paths. "
f"Detected shell: {_DETECTED_SHELL_NAME}. "
"</div>"
),
api_description=TOOL_SUMMARY,
flagging_mode="never",
submit_btn="Run",
)
__all__ = ["Shell_Command", "build_interface"]
|