- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
201 lines
7.3 KiB
Python
201 lines
7.3 KiB
Python
"""SSH sandbox -- executes commands on a remote host via OpenSSH.
|
|
|
|
Mirrors ``strands-ts/src/sandbox/ssh.ts``.
|
|
"""
|
|
|
|
import re
|
|
import shlex
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
|
|
from ..types.tools import AgentTool
|
|
from ..vended_tools.file_editor import make_file_editor
|
|
from ..vended_tools.file_editor.file_editor import DEFAULT_FILE_EDITOR_DESCRIPTION
|
|
from ..vended_tools.shell import make_shell
|
|
from ..vended_tools.shell.types import SANDBOX_SHELL_DESCRIPTION
|
|
from .posix_shell import PosixShellSandbox, build_shell_env_prefix
|
|
from .stream_process import _stream_process
|
|
from .types import ExecutionResult, StreamChunk
|
|
|
|
# Known-safe SSH options. Options that execute commands, tunnel traffic, or load
|
|
# external config are excluded. Reviewed and approved by AppSec.
|
|
# Full option reference: https://man.openbsd.org/ssh_config
|
|
_ALLOWED_SSH_OPTIONS = frozenset(
|
|
{
|
|
"addressfamily",
|
|
"bindaddress",
|
|
"bindinterface",
|
|
"canonicaldomains",
|
|
"canonicalizefallbacklocal",
|
|
"canonicalizehostname",
|
|
"canonicalizemaxdots",
|
|
"canonicalizepermittedcnames",
|
|
"checkhostip",
|
|
"ciphers",
|
|
"compression",
|
|
"connectionattempts",
|
|
"connecttimeout",
|
|
"hostkeyalgorithms",
|
|
"hostname",
|
|
"identitiesonly",
|
|
"ipqos",
|
|
"kbdinteractiveauthentication",
|
|
"kexalgorithms",
|
|
"loglevel",
|
|
"macs",
|
|
"numberofpasswordprompts",
|
|
"passwordauthentication",
|
|
"port",
|
|
"preferredauthentications",
|
|
"pubkeyacceptedalgorithms",
|
|
"pubkeyauthentication",
|
|
"rekeylimit",
|
|
"serveralivecountmax",
|
|
"serveraliveinterval",
|
|
"tcpkeepalive",
|
|
"updatehostkeys",
|
|
"user",
|
|
"verifyhostkeydns",
|
|
}
|
|
)
|
|
|
|
# Splits an SSH option on its first '=' or whitespace to isolate the option name,
|
|
# e.g. "ConnectTimeout=10" or 'Match exec "..."' -> the leading token.
|
|
_SSH_OPTION_NAME = re.compile(r"[=\s]")
|
|
|
|
|
|
class SshSandbox(PosixShellSandbox):
|
|
"""Execute commands on a remote host via SSH.
|
|
|
|
A thin :class:`PosixShellSandbox` backend: file and code operations are
|
|
inherited (run as shell commands), and only :meth:`execute_streaming` is
|
|
implemented, building the ``ssh`` argv.
|
|
|
|
Stateless -- each :meth:`execute_streaming` call spawns a fresh ``ssh``
|
|
process. All sessions use ``BatchMode=yes``, so interactive prompts are
|
|
disabled and authentication must be key-based.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str,
|
|
*,
|
|
working_dir: str,
|
|
identity_file: str | None = None,
|
|
port: int = 22,
|
|
ssh_options: list[str] | None = None,
|
|
allow_unknown_hosts: bool = False,
|
|
allow_unsafe_ssh_options: bool = False,
|
|
) -> None:
|
|
"""Initialize the SSH sandbox.
|
|
|
|
Args:
|
|
host: SSH destination (e.g. ``"user@host"``, ``"192.168.1.10"``).
|
|
working_dir: Working directory on the remote host.
|
|
identity_file: Path to an SSH private key file.
|
|
port: SSH port. Defaults to 22.
|
|
ssh_options: Additional SSH options passed as ``-o`` flags.
|
|
allow_unknown_hosts: Allow connections to hosts with unknown or
|
|
changed SSH keys. When ``False`` (default), uses
|
|
``StrictHostKeyChecking=accept-new`` (trust on first connect,
|
|
reject if the key changes). When ``True``, uses
|
|
``StrictHostKeyChecking=no`` (host key verification disabled).
|
|
allow_unsafe_ssh_options: Bypass the SSH option allowlist. When
|
|
``False`` (default), unknown options raise at construction. When
|
|
``True``, all options pass through without validation.
|
|
|
|
Raises:
|
|
ValueError: If ``ssh_options`` contains an option not on the
|
|
allowlist and ``allow_unsafe_ssh_options`` is ``False``.
|
|
"""
|
|
self.host = host
|
|
self.working_dir = working_dir
|
|
self._identity_file = identity_file
|
|
self._port = port
|
|
self._allow_unknown_hosts = allow_unknown_hosts
|
|
self._ssh_options = ssh_options or []
|
|
|
|
if not allow_unsafe_ssh_options:
|
|
for opt in self._ssh_options:
|
|
name = _SSH_OPTION_NAME.split(opt, maxsplit=1)[0]
|
|
if name.lower() not in _ALLOWED_SSH_OPTIONS:
|
|
raise ValueError(
|
|
f'SSH option "{name}" is not allowed. Set allow_unsafe_ssh_options=True to bypass.'
|
|
)
|
|
|
|
async def execute_streaming(
|
|
self,
|
|
command: str,
|
|
*,
|
|
timeout: float | None = None,
|
|
cwd: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
**kwargs: Any,
|
|
) -> AsyncGenerator[StreamChunk | ExecutionResult, None]:
|
|
"""Execute a command on the remote host, streaming output.
|
|
|
|
Args:
|
|
command: The shell command to execute.
|
|
timeout: Maximum execution time in seconds. ``None`` means no timeout.
|
|
cwd: Working directory for this command, overriding ``working_dir``.
|
|
env: Environment variables to set, applied via a shell ``export`` prefix.
|
|
**kwargs: Additional keyword arguments for forward compatibility.
|
|
|
|
Yields:
|
|
:class:`StreamChunk` objects for output, then a final
|
|
:class:`ExecutionResult`.
|
|
|
|
Raises:
|
|
ValueError: If an environment variable name is invalid.
|
|
SandboxTimeoutError: If execution exceeds ``timeout`` seconds.
|
|
"""
|
|
effective_cwd = cwd if cwd is not None else self.working_dir
|
|
env_prefix = build_shell_env_prefix(env)
|
|
remote_command = f"cd {shlex.quote(effective_cwd)} && {env_prefix}{command}"
|
|
|
|
args = [
|
|
"-o",
|
|
f"StrictHostKeyChecking={'no' if self._allow_unknown_hosts else 'accept-new'}",
|
|
"-o",
|
|
"BatchMode=yes",
|
|
"-p",
|
|
str(self._port),
|
|
]
|
|
|
|
if self._identity_file:
|
|
args += ["-i", self._identity_file]
|
|
|
|
for opt in self._ssh_options:
|
|
args += ["-o", opt]
|
|
|
|
# ssh requires the hostname and command after all flags. '--' terminates flag
|
|
# parsing so the host is always treated as a positional argument; without it, a
|
|
# host like '-oProxyCommand=evil' would be parsed as a flag, enabling arbitrary
|
|
# command execution on the local machine.
|
|
args += ["--", self.host, remote_command]
|
|
|
|
async for chunk in _stream_process(
|
|
"ssh", args, timeout=timeout, enoent_message="ssh is not installed or not on PATH"
|
|
):
|
|
yield chunk
|
|
|
|
def get_tools(self) -> list[AgentTool]:
|
|
"""Default sandbox-compatible tools auto-registered with this sandbox.
|
|
|
|
Returns:
|
|
The tools bound to this sandbox, with descriptions naming the remote host.
|
|
"""
|
|
return [
|
|
make_file_editor(
|
|
sandbox=self,
|
|
name="sandbox_file_editor",
|
|
description=f'{DEFAULT_FILE_EDITOR_DESCRIPTION} Files are on host "{self.host}".',
|
|
),
|
|
make_shell(
|
|
sandbox=self,
|
|
name="sandbox_shell",
|
|
description=(
|
|
f'{SANDBOX_SHELL_DESCRIPTION} Runs on host "{self.host}". Working directory: {self.working_dir}.'
|
|
),
|
|
),
|
|
]
|