fixes
This commit is contained in:
@@ -5,6 +5,20 @@ from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
def _detect_sudo_auth_failure(output: str) -> bool:
|
||||
normalized = output.lower()
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"incorrect password",
|
||||
"incorrect password attempt",
|
||||
"sudo: no password was provided",
|
||||
"sorry, try again",
|
||||
"authentication failure",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Tool(BaseTool):
|
||||
name = "shell_exec"
|
||||
description = "Execute shell commands"
|
||||
@@ -18,16 +32,24 @@ class Tool(BaseTool):
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing command", metadata={"exit_code": -1})
|
||||
cwd = args.get("cwd")
|
||||
stdin_secret = args.get("stdin_secret")
|
||||
output_callback = args.get("__output_callback")
|
||||
completed = self._sandbox.run_shell(
|
||||
command=command,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdin_data=str(stdin_secret) if stdin_secret is not None else None,
|
||||
output_callback=output_callback if callable(output_callback) else None,
|
||||
)
|
||||
output = completed.stdout if completed.returncode == 0 else completed.stderr or completed.stdout
|
||||
sudo_auth_failed = completed.returncode != 0 and _detect_sudo_auth_failure(
|
||||
f"{completed.stdout}\n{completed.stderr}"
|
||||
)
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=completed.returncode == 0,
|
||||
output=output,
|
||||
error=None if completed.returncode == 0 else f"Command failed with exit code {completed.returncode}",
|
||||
metadata={"exit_code": completed.returncode},
|
||||
metadata={
|
||||
"exit_code": completed.returncode,
|
||||
"sudo_auth_failed": sudo_auth_failed,
|
||||
},
|
||||
)
|
||||
|
||||
+107
-7
@@ -1,16 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class ToolSandbox:
|
||||
"""Applies simple working directory and timeout restrictions."""
|
||||
|
||||
def __init__(self, allowed_root: str | Path, timeout_ms: int) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
allowed_root: str | Path,
|
||||
timeout_ms: int,
|
||||
command_timeout_ms: int | None = None,
|
||||
idle_timeout_ms: int | None = None,
|
||||
) -> None:
|
||||
self._allowed_root = Path(allowed_root).resolve()
|
||||
self._timeout_seconds = max(timeout_ms / 1000, 1)
|
||||
self._timeout_seconds = max(timeout_ms / 1000, 0.001)
|
||||
self._command_timeout_seconds = max((command_timeout_ms or timeout_ms) / 1000, 0.001)
|
||||
self._idle_timeout_seconds = max((idle_timeout_ms or timeout_ms) / 1000, 0.001)
|
||||
|
||||
def ensure_path_allowed(self, path: str | Path) -> Path:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
@@ -23,17 +35,105 @@ class ToolSandbox:
|
||||
command: str,
|
||||
cwd: str | Path | None = None,
|
||||
stdin_data: str | None = None,
|
||||
output_callback: Callable[[str, str], None] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
working_directory = self.ensure_path_allowed(cwd or self._allowed_root)
|
||||
env = {"PATH": os.environ.get("PATH", "")}
|
||||
return subprocess.run(
|
||||
if output_callback is None:
|
||||
return subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(working_directory),
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
input=stdin_data,
|
||||
timeout=self._command_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(working_directory),
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
input=stdin_data,
|
||||
timeout=self._timeout_seconds,
|
||||
check=False,
|
||||
stdin=subprocess.PIPE if stdin_data is not None else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
stdout_chunks: list[str] = []
|
||||
stderr_chunks: list[str] = []
|
||||
output_lock = threading.Lock()
|
||||
last_output_at = time.monotonic()
|
||||
|
||||
if stdin_data is not None and process.stdin is not None:
|
||||
process.stdin.write(stdin_data)
|
||||
process.stdin.close()
|
||||
|
||||
def read_stream(stream_name: str) -> None:
|
||||
stream = process.stdout if stream_name == "stdout" else process.stderr
|
||||
if stream is None:
|
||||
return
|
||||
chunks = stdout_chunks if stream_name == "stdout" else stderr_chunks
|
||||
try:
|
||||
for line in iter(stream.readline, ""):
|
||||
if not line:
|
||||
break
|
||||
chunks.append(line)
|
||||
nonlocal last_output_at
|
||||
with output_lock:
|
||||
last_output_at = time.monotonic()
|
||||
output_callback(stream_name, line)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
stdout_thread = threading.Thread(target=read_stream, args=("stdout",), daemon=True)
|
||||
stderr_thread = threading.Thread(target=read_stream, args=("stderr",), daemon=True)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
|
||||
timed_out = False
|
||||
timeout_reason: str | None = None
|
||||
started_at = time.monotonic()
|
||||
return_code: int | None = None
|
||||
while return_code is None:
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
with output_lock:
|
||||
idle_for = now - last_output_at
|
||||
if now - started_at > self._command_timeout_seconds:
|
||||
timed_out = True
|
||||
timeout_reason = f"Command timed out after {self._command_timeout_seconds:.0f}s"
|
||||
break
|
||||
if idle_for > self._idle_timeout_seconds:
|
||||
timed_out = True
|
||||
timeout_reason = f"Command produced no output for {self._idle_timeout_seconds:.0f}s"
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if timed_out:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
process.kill()
|
||||
return_code = process.wait()
|
||||
timeout_message = f"{timeout_reason}\n"
|
||||
stderr_chunks.append(timeout_message)
|
||||
output_callback("stderr", timeout_message)
|
||||
|
||||
stdout_thread.join(timeout=1)
|
||||
stderr_thread.join(timeout=1)
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=return_code if not timed_out else -9,
|
||||
stdout="".join(stdout_chunks),
|
||||
stderr="".join(stderr_chunks),
|
||||
)
|
||||
|
||||
+26
-7
@@ -5,6 +5,22 @@ from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
def _detect_sudo_auth_failure(output: str) -> bool:
|
||||
normalized = output.lower()
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"incorrect password",
|
||||
"incorrect password attempt",
|
||||
"sudo: no password was provided",
|
||||
"sudo: password incorrect",
|
||||
"sorry, try again",
|
||||
"authentication failure",
|
||||
"wrong password",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ShellExecTool(BaseTool):
|
||||
name = "shell_exec"
|
||||
|
||||
@@ -18,6 +34,7 @@ class ShellExecTool(BaseTool):
|
||||
cwd = args.get("cwd")
|
||||
stdin_secret = args.get("stdin_secret")
|
||||
password = args.get("password")
|
||||
output_callback = args.get("__output_callback")
|
||||
|
||||
if password:
|
||||
command = f'echo "{password}" | sudo -S {command}'
|
||||
@@ -26,21 +43,23 @@ class ShellExecTool(BaseTool):
|
||||
command=command,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdin_data=str(stdin_secret) if stdin_secret is not None else None,
|
||||
output_callback=output_callback if callable(output_callback) else None,
|
||||
)
|
||||
output = completed.stdout if completed.returncode == 0 else completed.stderr or completed.stdout
|
||||
error_output = completed.stderr or completed.stdout
|
||||
is_sudo_error = (
|
||||
completed.returncode != 0 and
|
||||
("permission denied" in error_output.lower() or
|
||||
"incorrect password" in error_output.lower() or
|
||||
"sudo: password incorrect" in error_output.lower() or
|
||||
"wrong password" in error_output.lower())
|
||||
sudo_auth_failed = completed.returncode != 0 and _detect_sudo_auth_failure(
|
||||
f"{completed.stdout}\n{completed.stderr}"
|
||||
)
|
||||
needs_sudo = completed.returncode != 0 and "permission denied" in error_output.lower() and not sudo_auth_failed
|
||||
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=completed.returncode == 0,
|
||||
output=output,
|
||||
error=None if completed.returncode == 0 else f"Command failed with exit code {completed.returncode}",
|
||||
metadata={"exit_code": completed.returncode, "needs_sudo": is_sudo_error},
|
||||
metadata={
|
||||
"exit_code": completed.returncode,
|
||||
"needs_sudo": needs_sudo,
|
||||
"sudo_auth_failed": sudo_auth_failed,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user