2e5db60dc5
The runtime (llm/runtime.py, stream/stop.py) is a separate development line whose stop adapter drives LangGraph internals. Upstream v0.3.0 bumps its dependencies for currency, and two exact-version assertions in that adapter turned the bump into a silent regression: stop ownership was refused, so cancellation/continuation runs never reached a terminal state. Resolved without touching the adapter's logic: - stream/stop.py: claim ownership by *capability* instead of an exact version string. The internals the adapter swaps (_graph_aiter / _pump_cond / _exhausted / _aborting / _anext_task / _mux) and the SQLite saver's connection lock are present and identical in langgraph 1.2.6 and 1.2.11, and langgraph-checkpoint-sqlite 3.1.1 exposes the same barrier as 3.0.3. A new patch release can no longer disable stop ownership by being newer; a release that really drops the internals still fails closed with CHECKPOINT_STOP_ADAPTER_UNSUPPORTED. - EvoScientist.py: supply TodoListMiddleware only when deepagents' own default chain lacks it. deepagents 0.7 dropped it (upstream adds one back); 0.6.x still ships it, and a second instance collides by name in langchain's create_agent. - backends.py: fall back to a shape-compatible DeleteResult when deepagents has no delete support, so upstream v0.3.0's delete refusals import and run on either line. - tests/test_backends.py: gate the delete-behaviour tests on the framework actually providing backend deletion instead of asserting a specific stack. Verified: 4216 passed / 33 skipped / 29 failed / 16 errors — every remaining failure is pre-existing on the untouched pre-merge tree except two (a google-stream cleanup-order assertion and one webui launcher test).
1884 lines
67 KiB
Python
1884 lines
67 KiB
Python
"""Custom backends for EvoScientist agent."""
|
|
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from deepagents.backends import FilesystemBackend, LocalShellBackend
|
|
from deepagents.backends.protocol import (
|
|
BackendProtocol,
|
|
EditResult,
|
|
ExecuteResponse,
|
|
FileDownloadResponse,
|
|
FileUploadResponse,
|
|
GlobResult,
|
|
GrepResult,
|
|
LsResult,
|
|
WriteResult,
|
|
)
|
|
from filelock import FileLock
|
|
|
|
try: # deepagents>=0.7 adds file deletion; the Ai4Sci runtime line holds 0.6.x
|
|
from deepagents.backends.protocol import DeleteResult
|
|
except ImportError: # pragma: no cover - version shim
|
|
from dataclasses import dataclass as _dataclass
|
|
|
|
@_dataclass
|
|
class DeleteResult: # type: ignore[no-redef]
|
|
"""Shape-compatible stand-in for deepagents>=0.7's ``DeleteResult``.
|
|
|
|
Upstream v0.3.0 implements ``delete`` refusals on several backends.
|
|
deepagents 0.6.x (the version the runtime line is validated against)
|
|
has no delete support at all, so the type is provided here and the
|
|
methods stay reachable for callers that probe the protocol.
|
|
"""
|
|
|
|
error: str | None = None
|
|
path: str | None = None
|
|
|
|
|
|
from . import paths
|
|
from .cancellation import current_cancel_event
|
|
|
|
if TYPE_CHECKING:
|
|
from langgraph.types import Command
|
|
|
|
# Reproduced here to dodge a circular import from .EvoScientist (the canonical
|
|
# SKILLS_DIR constant).
|
|
_BUILTIN_SKILLS_DIR = Path(__file__).parent / "skills"
|
|
|
|
# System path prefixes that should never appear in virtual paths.
|
|
# If the agent hallucinates an absolute system path, we block it.
|
|
_SYSTEM_PATH_PREFIXES = (
|
|
"/Users/",
|
|
"/home/",
|
|
"/tmp/",
|
|
"/var/",
|
|
"/etc/",
|
|
"/opt/",
|
|
"/usr/",
|
|
"/bin/",
|
|
"/sbin/",
|
|
"/dev/",
|
|
"/proc/",
|
|
"/sys/",
|
|
"/root/",
|
|
)
|
|
|
|
# Path-confinement patterns: keep the agent inside the workspace. These are
|
|
# bypassed in dangerous mode (real-filesystem access).
|
|
_PATH_PATTERNS = [
|
|
r"~/", # home directory
|
|
r"\bcd\s+/", # cd to absolute path
|
|
]
|
|
# Destructive patterns: catastrophic regardless of mode — always enforced.
|
|
_DESTRUCTIVE_PATTERNS = [
|
|
r"\brm\s+-rf\s+/", # rm -rf with absolute path
|
|
]
|
|
|
|
# Dangerous commands that should never be executed
|
|
BLOCKED_COMMANDS = [
|
|
"sudo",
|
|
"chmod",
|
|
"chown",
|
|
"mkfs",
|
|
"dd",
|
|
"shutdown",
|
|
"reboot",
|
|
]
|
|
|
|
|
|
_active_shell_processes_lock = threading.RLock()
|
|
_active_shell_processes: dict[threading.Event, set[subprocess.Popen[str]]] = {}
|
|
_PROCESS_DRAIN_GRACE_SECONDS = 1.0
|
|
|
|
|
|
def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
|
|
"""Force-stop a shell and its descendants without waiting for reaping."""
|
|
# A completed Popen has already reaped its PID, which the OS may reuse.
|
|
# Inspect the recorded state rather than calling poll(): an exited but
|
|
# unreaped shell can still have live descendants in its process group.
|
|
if process.returncode is not None:
|
|
return
|
|
|
|
try:
|
|
if os.name == "nt":
|
|
# CREATE_NEW_PROCESS_GROUP alone does not make terminate() recursive.
|
|
# taskkill is the native way to stop the complete descendant tree.
|
|
subprocess.run(
|
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
check=False,
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
else:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except (OSError, subprocess.SubprocessError):
|
|
try:
|
|
process.kill()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _stop_collecting_process_output(process: subprocess.Popen[str]) -> None:
|
|
"""Close inherited pipes and reap *process* without blocking the caller."""
|
|
for pipe in (process.stdout, process.stderr):
|
|
if pipe is not None:
|
|
try:
|
|
pipe.close()
|
|
except OSError:
|
|
pass
|
|
|
|
if process.poll() is None:
|
|
threading.Thread(target=process.wait, daemon=True).start()
|
|
|
|
|
|
def cancel_active_shell_processes(event: threading.Event) -> None:
|
|
"""Terminate every active shell command associated with *event*."""
|
|
with _active_shell_processes_lock:
|
|
processes = tuple(_active_shell_processes.get(event, ()))
|
|
for process in processes:
|
|
_terminate_process_tree(process)
|
|
|
|
|
|
def _register_shell_process(
|
|
event: threading.Event | None,
|
|
process: subprocess.Popen[str],
|
|
) -> None:
|
|
if event is None:
|
|
return
|
|
with _active_shell_processes_lock:
|
|
_active_shell_processes.setdefault(event, set()).add(process)
|
|
cancel_now = event.is_set()
|
|
if cancel_now:
|
|
_terminate_process_tree(process)
|
|
|
|
|
|
def _unregister_shell_process(
|
|
event: threading.Event | None,
|
|
process: subprocess.Popen[str],
|
|
) -> None:
|
|
if event is None:
|
|
return
|
|
with _active_shell_processes_lock:
|
|
processes = _active_shell_processes.get(event)
|
|
if processes is None:
|
|
return
|
|
processes.discard(process)
|
|
if not processes:
|
|
_active_shell_processes.pop(event, None)
|
|
|
|
|
|
def _shell_token_spans(command: str) -> list[dict[str, object]]:
|
|
"""Tokenize enough shell syntax to find quoted SSH remote commands.
|
|
|
|
This is intentionally small: it tracks words, quotes, and command
|
|
separators, but does not try to be a full POSIX shell parser.
|
|
"""
|
|
tokens: list[dict[str, object]] = []
|
|
i = 0
|
|
n = len(command)
|
|
|
|
def read_operator(index: int) -> str | None:
|
|
if command.startswith(("&&", "||"), index):
|
|
return command[index : index + 2]
|
|
if command.startswith("&>", index):
|
|
return "&>"
|
|
ch = command[index]
|
|
if ch in "`();|&":
|
|
return ch
|
|
if ch in "<>":
|
|
# `>>`/`<<` and `>|` (force-clobber redirect) are single redirection
|
|
# operators, NOT a pipe — the trailing `|` must not read as a boundary.
|
|
if index + 1 < n and (
|
|
command[index + 1] == ch or (ch == ">" and command[index + 1] == "|")
|
|
):
|
|
return command[index : index + 2]
|
|
return ch
|
|
if ch.isdigit():
|
|
j = index
|
|
while j < n and command[j].isdigit():
|
|
j += 1
|
|
if j < n and command[j] in "<>":
|
|
end = j + 1
|
|
# `2>&1`, `2>>`, and `2>|` (fd force-clobber) are single
|
|
# redirection operators — the trailing `|` is not a pipe.
|
|
if end < n and command[end] in ("&", "|", command[j]):
|
|
end += 1
|
|
return command[index:end]
|
|
return None
|
|
|
|
while i < n:
|
|
if command[i].isspace():
|
|
i += 1
|
|
continue
|
|
operator = read_operator(i)
|
|
if operator is not None:
|
|
tokens.append(
|
|
{"type": "op", "value": operator, "start": i, "end": i + len(operator)}
|
|
)
|
|
i += len(operator)
|
|
continue
|
|
|
|
start = i
|
|
value: list[str] = []
|
|
quoted = False
|
|
while i < n:
|
|
ch = command[i]
|
|
if ch.isspace():
|
|
break
|
|
if read_operator(i) is not None:
|
|
break
|
|
if ch in ("'", '"'):
|
|
quoted = True
|
|
quote = ch
|
|
i += 1
|
|
while i < n:
|
|
inner = command[i]
|
|
if inner == quote:
|
|
i += 1
|
|
break
|
|
if inner == "\\" and quote == '"' and i + 1 < n:
|
|
value.append(command[i + 1])
|
|
i += 2
|
|
else:
|
|
value.append(inner)
|
|
i += 1
|
|
continue
|
|
if ch == "\\" and i + 1 < n:
|
|
value.append(command[i + 1])
|
|
i += 2
|
|
continue
|
|
value.append(ch)
|
|
i += 1
|
|
|
|
tokens.append(
|
|
{
|
|
"type": "word",
|
|
"value": "".join(value),
|
|
"raw": command[start:i],
|
|
"start": start,
|
|
"end": i,
|
|
"quoted": quoted,
|
|
}
|
|
)
|
|
return tokens
|
|
|
|
|
|
# Commands that are dangerous as the RIGHT-HAND SIDE of a pipe (they consume
|
|
# piped data as code or ship it off-box). Everything else piping is normal.
|
|
_PIPE_NETWORKING_RHS = frozenset(
|
|
{
|
|
"nc",
|
|
"ncat",
|
|
"netcat",
|
|
"ssh",
|
|
"curl",
|
|
"wget",
|
|
"telnet",
|
|
"socat",
|
|
"scp",
|
|
"sftp",
|
|
"rsync",
|
|
"ftp",
|
|
}
|
|
)
|
|
_PIPE_INTERPRETER_RHS = frozenset(
|
|
{
|
|
"sh",
|
|
"bash",
|
|
"zsh",
|
|
"dash",
|
|
"ash",
|
|
"ksh",
|
|
"fish",
|
|
"python",
|
|
"python2",
|
|
"python3",
|
|
"node",
|
|
"bun",
|
|
"deno",
|
|
"ruby",
|
|
"perl",
|
|
"php",
|
|
"lua",
|
|
"iex",
|
|
"elixir",
|
|
}
|
|
)
|
|
_PIPE_DANGEROUS_RHS = _PIPE_INTERPRETER_RHS | _PIPE_NETWORKING_RHS
|
|
|
|
|
|
def check_dangerous_command(command: str) -> str | None:
|
|
"""Return a reason if *command* pipes output into an interpreter or a
|
|
network tool, else ``None``.
|
|
|
|
Deliberately narrow: this guards indirect prompt injection (the agent
|
|
ingests untrusted web content and could be induced to run
|
|
``curl … | bash``). Everyday research shell — pipes into ``grep``/``head``,
|
|
redirects, ``python -c``, ``..``/``~`` paths — is NOT flagged here.
|
|
Workspace confinement stays in :func:`validate_command`.
|
|
|
|
Only the token immediately after the pipe is inspected, so wrapper
|
|
commands like ``env bash``, ``xargs bash``, or ``timeout 5 bash`` are
|
|
not detected — this is a known limitation, not a bug to fix here.
|
|
"""
|
|
after_pipe = False
|
|
for token in _shell_token_spans(command):
|
|
if token.get("type") == "op":
|
|
value = token.get("value")
|
|
if value == "|":
|
|
after_pipe = True
|
|
elif value == "&" and after_pipe:
|
|
# `|&` (pipe stdout+stderr) tokenizes as `|` then `&`;
|
|
# keep the pipe context open across the `&`.
|
|
pass
|
|
else:
|
|
after_pipe = False
|
|
continue
|
|
if after_pipe:
|
|
base = str(token.get("value", "")).split("/")[-1]
|
|
# strip trailing version digits: python3.11 -> python, lua5.4 -> lua
|
|
normalized = re.sub(r"[0-9.]+$", "", base) or base
|
|
if base in _PIPE_DANGEROUS_RHS or normalized in _PIPE_DANGEROUS_RHS:
|
|
kind = (
|
|
"networking tool"
|
|
if base in _PIPE_NETWORKING_RHS
|
|
or normalized in _PIPE_NETWORKING_RHS
|
|
else "interpreter"
|
|
)
|
|
return f"pipes output into {kind} '{base}'"
|
|
after_pipe = False
|
|
return None
|
|
|
|
|
|
class ActionDecision(StrEnum):
|
|
"""Outcome of the shell-action policy."""
|
|
|
|
APPROVE = "approve"
|
|
REJECT = "reject"
|
|
PROMPT = "prompt"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActionVerdict:
|
|
"""A decision plus the reason to show the user or feed back to the agent."""
|
|
|
|
decision: ActionDecision
|
|
reason: str = ""
|
|
|
|
|
|
def resolve_action_decision(
|
|
command: str,
|
|
*,
|
|
auto_approve: bool = False,
|
|
dangerous_mode: bool = False,
|
|
allow_list: list[str] | None = None,
|
|
) -> ActionVerdict:
|
|
"""Single source of truth for approve / reject / prompt.
|
|
|
|
Precedence:
|
|
1. ``dangerous_mode`` — the user asked for full power; run everything.
|
|
2. dangerous detection — pipe into interpreter/network.
|
|
3. ``auto_approve`` — opt-out means *never prompt*: approve, or reject
|
|
a dangerous command with a reason the agent can act on.
|
|
4. ``allow_list`` — case-sensitive match on a whole command or a
|
|
command-plus-space prefix; blank entries are ignored. Every segment of
|
|
a chain (``a; b``, ``a && b``, ``a | b``) must match, so an allow-listed
|
|
prefix cannot carry a non-listed command in behind it.
|
|
"""
|
|
if dangerous_mode:
|
|
return ActionVerdict(ActionDecision.APPROVE)
|
|
|
|
reason = check_dangerous_command(command)
|
|
|
|
if auto_approve:
|
|
if reason:
|
|
return ActionVerdict(ActionDecision.REJECT, reason)
|
|
return ActionVerdict(ActionDecision.APPROVE)
|
|
|
|
if reason:
|
|
return ActionVerdict(ActionDecision.PROMPT, reason)
|
|
|
|
if allow_list:
|
|
prefixes = [p.strip() for p in allow_list if p.strip()]
|
|
# Match on a token boundary so allow-listing `ls` does not also approve
|
|
# `lsof` (case-sensitive, like the shell). Require EVERY segment of a
|
|
# chain to match, so `ls; rm -rf x` cannot ride in on an allow-listed
|
|
# `ls`. ``None`` means an unparseable construct (substitution/newline) —
|
|
# decline rather than risk approving a hidden command.
|
|
segments = _split_command_segments(command)
|
|
if segments is not None:
|
|
segments = segments or [command.strip()]
|
|
if prefixes and all(
|
|
any(seg == p or seg.startswith(p + " ") for p in prefixes)
|
|
for seg in segments
|
|
):
|
|
return ActionVerdict(ActionDecision.APPROVE)
|
|
|
|
return ActionVerdict(ActionDecision.PROMPT)
|
|
|
|
|
|
def build_hitl_resume(interrupt_id: str, decisions: list[dict]) -> "Command":
|
|
"""Build a HITL resume Command keyed by interrupt_id.
|
|
|
|
Keying by id (not the flat ``{"decisions": …}``) is REQUIRED whenever the
|
|
graph has more than one pending interrupt — parallel sub-agents that each
|
|
call ``execute`` do exactly that, and a flat resume raises
|
|
``RuntimeError: When there are multiple pending interrupts …``. Resuming a
|
|
single id resolves that interrupt and re-parks the rest (they re-emit on the
|
|
next stream), so callers drain them one at a time. Safe for N=1 too.
|
|
"""
|
|
from langgraph.types import Command
|
|
|
|
return Command(resume={interrupt_id: {"decisions": decisions}})
|
|
|
|
|
|
_SSH_OPTIONS_WITH_VALUE = {
|
|
"-B",
|
|
"-b",
|
|
"-c",
|
|
"-D",
|
|
"-E",
|
|
"-e",
|
|
"-F",
|
|
"-I",
|
|
"-i",
|
|
"-J",
|
|
"-L",
|
|
"-l",
|
|
"-m",
|
|
"-O",
|
|
"-o",
|
|
"-p",
|
|
"-Q",
|
|
"-R",
|
|
"-S",
|
|
"-W",
|
|
"-w",
|
|
}
|
|
|
|
|
|
def _ssh_option_consumes_next(token: str) -> bool:
|
|
"""Return whether an SSH option token consumes the following argument."""
|
|
if token in _SSH_OPTIONS_WITH_VALUE:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_ssh_executable(token: str) -> bool:
|
|
return token == "ssh"
|
|
|
|
|
|
def _is_shell_assignment(token: dict[str, object]) -> bool:
|
|
raw = str(token.get("raw", token.get("value", "")))
|
|
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", raw) is not None
|
|
|
|
|
|
def _ssh_executable_index(words: list[dict[str, object]]) -> int | None:
|
|
idx = 0
|
|
while idx < len(words) and _is_shell_assignment(words[idx]):
|
|
idx += 1
|
|
if idx < len(words) and _is_ssh_executable(str(words[idx].get("value", ""))):
|
|
return idx
|
|
return None
|
|
|
|
|
|
def _is_single_quoted_word(token: dict[str, object]) -> bool:
|
|
raw = str(token.get("raw", ""))
|
|
return raw.startswith("'") and raw.endswith("'")
|
|
|
|
|
|
def _ssh_host_index(words: list[dict[str, object]], ssh_idx: int) -> int:
|
|
"""Return the index of the host argument (first non-option after ssh)."""
|
|
idx = ssh_idx + 1
|
|
while idx < len(words):
|
|
value = str(words[idx].get("value", ""))
|
|
if value == "--":
|
|
idx += 1
|
|
break
|
|
if value.startswith("-") and value != "-":
|
|
idx += 2 if _ssh_option_consumes_next(value) else 1
|
|
continue
|
|
break
|
|
return idx
|
|
|
|
|
|
def _ssh_invocations(
|
|
command: str,
|
|
) -> list[tuple[list[dict[str, object]], int, int, int | None, int]]:
|
|
"""Return SSH invocations as ``(words, ssh_idx, host_idx, remote_idx, extra)``.
|
|
|
|
Examples:
|
|
>>> [(i, h, r, e) for _, i, h, r, e in _ssh_invocations("ssh host")]
|
|
[(0, 1, None, 0)]
|
|
>>> [(i, h, r, e) for _, i, h, r, e in _ssh_invocations("ssh host 'pwd'")]
|
|
[(0, 1, 2, 0)]
|
|
>>> [(i, h, r, e) for _, i, h, r, e in _ssh_invocations('ssh host "pwd"')]
|
|
[(0, 1, 2, 0)]
|
|
>>> [(i, h, r, e) for _, i, h, r, e in _ssh_invocations("ssh host 'pwd' extra")]
|
|
[(0, 1, 2, 1)]
|
|
>>> [(i, h, r, e) for _, i, h, r, e in _ssh_invocations("cat x && ssh -p 22 host 'pwd'")]
|
|
[(0, 3, 4, 0)]
|
|
>>> _ssh_invocations("/tmp/ssh host 'pwd'")
|
|
[]
|
|
"""
|
|
tokens = _shell_token_spans(command)
|
|
invocations: list[tuple[list[dict[str, object]], int, int, int | None, int]] = []
|
|
segment: list[dict[str, object]] = []
|
|
|
|
def flush_segment() -> None:
|
|
if not segment:
|
|
return
|
|
words = [tok for tok in segment if tok.get("type") == "word"]
|
|
ssh_idx = _ssh_executable_index(words)
|
|
if ssh_idx is None:
|
|
return
|
|
|
|
host_idx = _ssh_host_index(words, ssh_idx)
|
|
remote_idx = host_idx + 1 if host_idx + 1 < len(words) else None
|
|
remote_extra_argv_count = (
|
|
max(0, len(words) - remote_idx - 1) if remote_idx is not None else 0
|
|
)
|
|
invocations.append(
|
|
(words, ssh_idx, host_idx, remote_idx, remote_extra_argv_count)
|
|
)
|
|
|
|
for token in tokens:
|
|
if token.get("type") == "op":
|
|
flush_segment()
|
|
segment = []
|
|
else:
|
|
segment.append(token)
|
|
flush_segment()
|
|
return invocations
|
|
|
|
|
|
def _ssh_remote_command_spans(command: str) -> list[tuple[int, int]]:
|
|
"""Return remote-command argv spans in SSH invocations.
|
|
|
|
Only the supported single-quoted token after the destination host is treated
|
|
as remote argv. Plain ``ssh host`` has no remote argv and returns no spans.
|
|
|
|
Examples:
|
|
>>> _ssh_remote_command_spans("ssh host 'ls /home/u/project'")
|
|
[(9, 29)]
|
|
>>> _ssh_remote_command_spans('ssh host "ls /home/u/project"')
|
|
[]
|
|
>>> _ssh_remote_command_spans("cat /tmp/x && ssh host 'pwd'")
|
|
[(23, 28)]
|
|
"""
|
|
spans: list[tuple[int, int]] = []
|
|
for words, ssh_idx, host_idx, remote_idx, _ in _ssh_invocations(command):
|
|
# Mask the SSH executable path itself (e.g., /usr/bin/ssh) so
|
|
# virtual path conversion doesn't rewrite it.
|
|
spans.append(
|
|
(
|
|
int(words[ssh_idx]["start"]),
|
|
int(words[ssh_idx]["end"]),
|
|
)
|
|
)
|
|
|
|
if (
|
|
host_idx < len(words)
|
|
and remote_idx is not None
|
|
and _is_single_quoted_word(words[remote_idx])
|
|
):
|
|
spans.append(
|
|
(
|
|
int(words[remote_idx]["start"]),
|
|
int(words[remote_idx]["end"]),
|
|
)
|
|
)
|
|
return spans
|
|
|
|
|
|
def _mask_spans(
|
|
command: str, spans: list[tuple[int, int]]
|
|
) -> tuple[str, dict[str, str]]:
|
|
"""Replace spans with placeholders and return the restoration map."""
|
|
if not spans:
|
|
return command, {}
|
|
pieces: list[str] = []
|
|
replacements: dict[str, str] = {}
|
|
cursor = 0
|
|
nonce = uuid.uuid4().hex
|
|
for index, (start, end) in enumerate(sorted(spans)):
|
|
if start < cursor:
|
|
continue
|
|
placeholder = f"__EVOSCI_SSH_REMOTE_{nonce}_{index}__"
|
|
pieces.append(command[cursor:start])
|
|
pieces.append(placeholder)
|
|
replacements[placeholder] = command[start:end]
|
|
cursor = end
|
|
pieces.append(command[cursor:])
|
|
return "".join(pieces), replacements
|
|
|
|
|
|
def _restore_spans(command: str, replacements: dict[str, str]) -> str:
|
|
for placeholder, original in replacements.items():
|
|
command = command.replace(placeholder, original)
|
|
return command
|
|
|
|
|
|
def _mask_ssh_remote_commands(command: str) -> tuple[str, dict[str, str]]:
|
|
"""Mask supported SSH remote argv so local path logic can skip it.
|
|
|
|
Examples:
|
|
>>> _restore_spans(*_mask_ssh_remote_commands("ssh host 'pwd'"))
|
|
"ssh host 'pwd'"
|
|
"""
|
|
return _mask_spans(command, _ssh_remote_command_spans(command))
|
|
|
|
|
|
def _validate_ssh_remote_command_format(command: str) -> str | None:
|
|
"""Require SSH remote commands to be one single-quoted token after the host."""
|
|
|
|
def error() -> str:
|
|
return (
|
|
"SSH remote commands must be passed as a single quoted argument, "
|
|
"for example: ssh host 'cd /home/user/project && python train.py'."
|
|
)
|
|
|
|
for words, _, _, remote_idx, remote_extra_argv_count in _ssh_invocations(command):
|
|
if remote_idx is None:
|
|
continue
|
|
if not _is_single_quoted_word(words[remote_idx]):
|
|
return error()
|
|
if remote_extra_argv_count:
|
|
return error()
|
|
return None
|
|
|
|
|
|
def _split_shell_commands(command: str) -> list[str]:
|
|
"""Split a compound shell command into individual base commands.
|
|
|
|
Handles command-boundary shell operators tracked by ``_shell_token_spans``.
|
|
Redirection operators are not boundaries; their operands are filenames, not
|
|
commands.
|
|
"""
|
|
command_boundaries = {"&&", "||", ";", "|", "&", "(", ")", "`"}
|
|
base_commands: list[str] = []
|
|
segment: list[str] = []
|
|
|
|
def flush_segment() -> None:
|
|
words = [token for token in segment if token]
|
|
if words:
|
|
base_commands.append(words[0])
|
|
|
|
for token in _shell_token_spans(command):
|
|
if token.get("type") == "op" and token.get("value") in command_boundaries:
|
|
flush_segment()
|
|
segment = []
|
|
else:
|
|
segment.append(str(token.get("value", "")))
|
|
flush_segment()
|
|
return base_commands
|
|
|
|
|
|
def _split_command_segments(command: str) -> list[str] | None:
|
|
"""Split a compound command into raw segment strings on command boundaries.
|
|
|
|
Quote-aware (via ``_shell_token_spans``). Boundaries are ``;`` ``&&`` ``||``
|
|
``|`` ``&`` and grouping; redirections are not boundaries. Lets the allow-list
|
|
clear a chain only when *every* segment is allow-listed, not just the leading
|
|
one (``ls; rm -rf x`` must not ride in on an allow-listed ``ls``).
|
|
|
|
Returns ``None`` when the command contains a construct this small tokenizer
|
|
cannot safely reason about — command substitution (``$(...)`` or backticks,
|
|
which run a hidden command even inside double quotes) or a newline separator —
|
|
so the caller declines to allow-list it rather than approve a hidden command.
|
|
Deliberately a substring over-approximation: a literal/quoted ``$(``, backtick,
|
|
or newline also declines (a safe extra prompt, never a bypass). Quote/escape
|
|
awareness is intentionally not attempted — that fragility caused the original
|
|
chaining gap.
|
|
"""
|
|
if "$(" in command or "`" in command or "\n" in command or "\r" in command:
|
|
return None
|
|
boundaries = {"&&", "||", ";", "|", "&", "(", ")"}
|
|
segments: list[str] = []
|
|
seg_start = 0
|
|
for token in _shell_token_spans(command):
|
|
if token.get("type") == "op" and token.get("value") in boundaries:
|
|
seg = command[seg_start : int(token["start"])].strip()
|
|
if seg:
|
|
segments.append(seg)
|
|
seg_start = int(token["end"])
|
|
tail = command[seg_start:].strip()
|
|
if tail:
|
|
segments.append(tail)
|
|
return segments
|
|
|
|
|
|
def _has_traversal_component(command: str) -> bool:
|
|
"""Check if command contains '..' as a path component (not substring)."""
|
|
from pathlib import PurePosixPath
|
|
|
|
for token in command.split():
|
|
if ".." in PurePosixPath(token).parts:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _collect_executable_positions(command: str) -> set[int]:
|
|
"""Return the string offsets of executable tokens (first token per segment).
|
|
|
|
These are command names/paths that appear in executable position (e.g.
|
|
``/usr/bin/python`` in ``/usr/bin/python script.py``) and should not be
|
|
treated as dangerous operand paths. Also covers the argument position
|
|
right after ``pip install`` / ``pip3 install`` (package path).
|
|
"""
|
|
offsets: set[int] = set()
|
|
for segment in re.split(r"\s*(?:&&|\|\||;)\s*", command):
|
|
for pipe_seg in segment.split("|"):
|
|
pipe_seg_stripped = pipe_seg.strip()
|
|
if not pipe_seg_stripped:
|
|
continue
|
|
# Offset of this pipe segment within *command*
|
|
seg_start = command.find(pipe_seg_stripped)
|
|
try:
|
|
tokens = shlex.split(pipe_seg_stripped)
|
|
except ValueError:
|
|
tokens = pipe_seg_stripped.split()
|
|
if not tokens:
|
|
continue
|
|
# First token is the executable itself — mark its offset
|
|
offsets.add(seg_start)
|
|
# pip install <path> — mark the install-target token
|
|
if (
|
|
len(tokens) >= 3
|
|
and tokens[0] in ("pip", "pip3")
|
|
and tokens[1] == "install"
|
|
):
|
|
# Find position of the 3rd token (the package arg) onwards
|
|
rest = pipe_seg_stripped
|
|
for t in tokens[:2]:
|
|
idx = rest.find(t)
|
|
rest = rest[idx + len(t) :]
|
|
pkg_offset = seg_start + (len(pipe_seg_stripped) - len(rest.lstrip()))
|
|
offsets.add(pkg_offset)
|
|
return offsets
|
|
|
|
|
|
def _is_under_allowed_prefix(path: str, allow_prefixes: tuple[str, ...]) -> bool:
|
|
"""True if *path* equals a prefix or is a strict descendant.
|
|
|
|
Boundary-aware: ``str.startswith`` alone would let ``/A/skills_evil``
|
|
match the prefix ``/A/skills`` — anchoring on ``/`` blocks neighbour
|
|
directories that merely share a name prefix.
|
|
"""
|
|
for prefix in allow_prefixes:
|
|
normalized = prefix.rstrip("/")
|
|
# Skip empty/root prefixes: they'd reduce the check to startswith("/")
|
|
# and admit every absolute path, silently disabling the allowlist.
|
|
if not normalized:
|
|
continue
|
|
if path == normalized or path.startswith(normalized + "/"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _extract_all_paths(
|
|
command: str,
|
|
allow_prefixes: tuple[str, ...] = (),
|
|
) -> list[str]:
|
|
"""Extract potential file paths from a command, including inside quoted strings.
|
|
|
|
Scans both shell tokens and string literals (single/double quoted) to find
|
|
paths that start with system prefixes like /Users/, /etc/, /tmp/, etc.
|
|
Skips paths in executable position (command name) and pip install targets.
|
|
|
|
Paths matched by ``allow_prefixes`` (via ``_is_under_allowed_prefix``)
|
|
are dropped.
|
|
"""
|
|
exe_offsets = _collect_executable_positions(command)
|
|
paths: list[str] = []
|
|
# Pattern: match absolute paths starting with / followed by word chars, dots,
|
|
# dashes, slashes. Looks inside quotes and unquoted tokens alike.
|
|
# Excludes URL-like patterns (preceded by ://)
|
|
path_re = re.compile(
|
|
r"(?<![:=/.\w])" # not preceded by :, =, /, ., or word char (avoid URLs, env vars, ./paths)
|
|
r"(/(?:Users|home|tmp|var|etc|opt|usr|bin|sbin|dev|proc|sys|root)"
|
|
r'(?:/[^\s\'",;|&<>)}\]]*)?)' # rest of the path
|
|
)
|
|
for m in path_re.finditer(command):
|
|
# Skip paths that land at an executable-position offset
|
|
if m.start(1) in exe_offsets:
|
|
continue
|
|
extracted = m.group(1)
|
|
if _is_under_allowed_prefix(extracted, allow_prefixes):
|
|
continue
|
|
paths.append(extracted)
|
|
return paths
|
|
|
|
|
|
def validate_command(
|
|
command: str,
|
|
allow_prefixes: tuple[str, ...] = (),
|
|
*,
|
|
dangerous: bool = False,
|
|
) -> str | None:
|
|
"""
|
|
Validate a shell command for safety.
|
|
|
|
Args:
|
|
command: Shell command string.
|
|
allow_prefixes: Absolute path prefixes exempt from the system-path
|
|
block list (matching rules in ``_is_under_allowed_prefix``).
|
|
dangerous: When True (real-filesystem mode), skip the path-confinement
|
|
checks (``..`` traversal, ``~/``/``cd /`` patterns, absolute system
|
|
paths). Privileged commands (:data:`BLOCKED_COMMANDS`) and
|
|
catastrophic patterns (:data:`_DESTRUCTIVE_PATTERNS`) are still
|
|
enforced.
|
|
|
|
Returns:
|
|
None if command is safe, error message string if blocked.
|
|
"""
|
|
# Path-confinement checks — skipped in dangerous mode.
|
|
if not dangerous:
|
|
# Check for '..' path traversal as a path component
|
|
if _has_traversal_component(command):
|
|
return (
|
|
"Command blocked: contains '..' path traversal. "
|
|
"All commands must operate within the workspace directory. "
|
|
"Use relative paths (e.g., './file.py') instead."
|
|
)
|
|
|
|
for pattern in _PATH_PATTERNS:
|
|
if re.search(pattern, command):
|
|
return (
|
|
f"Command blocked: contains forbidden pattern '{pattern}'. "
|
|
f"All commands must operate within the workspace directory. "
|
|
f"Use relative paths (e.g., './file.py') instead."
|
|
)
|
|
|
|
# Catastrophic patterns (e.g. `rm -rf /`) — always enforced.
|
|
for pattern in _DESTRUCTIVE_PATTERNS:
|
|
if re.search(pattern, command):
|
|
return (
|
|
f"Command blocked: contains forbidden pattern '{pattern}'. "
|
|
f"All commands must operate within the workspace directory. "
|
|
f"Use relative paths (e.g., './file.py') instead."
|
|
)
|
|
|
|
# Check for dangerous commands (pipeline-aware) — always enforced.
|
|
for base_cmd in _split_shell_commands(command):
|
|
if base_cmd in BLOCKED_COMMANDS:
|
|
return (
|
|
f"Command blocked: '{base_cmd}' is not allowed in sandbox mode. "
|
|
f"Only standard development commands are permitted."
|
|
)
|
|
|
|
# Absolute-system-path check — skipped in dangerous mode.
|
|
# Catches attacks like: python -c "os.remove('/Users/foo/file')"
|
|
if not dangerous:
|
|
escaped_paths = _extract_all_paths(command, allow_prefixes=allow_prefixes)
|
|
if escaped_paths:
|
|
path_sample = escaped_paths[0]
|
|
return (
|
|
f"Command blocked: contains absolute system path '{path_sample}'. "
|
|
f"All file operations must use relative paths within the workspace. "
|
|
f"Use relative paths (e.g., './file.py') instead."
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _subpath_under_mount(token: str, mount: str) -> str | None:
|
|
"""Return the subpath of *token* under *mount*, or ``None`` if not under it.
|
|
|
|
Bare ``mount`` and ``mount + "/"`` both return ``""`` so the caller can
|
|
join uniformly (``Path(tier) / ""`` is the tier itself).
|
|
"""
|
|
if token == mount or token == mount + "/":
|
|
return ""
|
|
prefix = mount + "/"
|
|
if token.startswith(prefix):
|
|
return token[len(prefix) :]
|
|
return None
|
|
|
|
|
|
def _skills_tier_paths() -> tuple[Path, Path | None, Path]:
|
|
"""``(USER, GLOBAL or None, BUILTIN)`` — the tier priority chain that
|
|
``MergedSkillsBackend._backends()`` honors. Single source of truth so
|
|
the resolver and the backend can't silently drift out of order.
|
|
"""
|
|
return (paths.USER_SKILLS_DIR, paths.GLOBAL_SKILLS_DIR, _BUILTIN_SKILLS_DIR)
|
|
|
|
|
|
def _is_windows() -> bool:
|
|
return sys.platform == "win32"
|
|
|
|
|
|
def _cmd_quote(s: str) -> str:
|
|
"""Quote *s* for cmd.exe using double-quote wrapping.
|
|
|
|
cmd.exe strips outer double quotes; content between them is taken
|
|
literally. Backslashes are not escape chars inside double quotes, so
|
|
Windows paths pass through unchanged. Embedded ``"`` is escaped as
|
|
``\"``; bare paths with no shell-special chars need no quoting at all.
|
|
|
|
.. note::
|
|
|
|
``%VAR%`` expansion is **not** neutralised here. Variable expansion
|
|
happens before quote processing in cmd.exe, and ``%%`` collapsing
|
|
only occurs inside ``.bat``/``.cmd`` files — not via ``cmd /c``.
|
|
This is acceptable because virtual-mount paths (skills, memories)
|
|
should never contain percent signs in practice.
|
|
|
|
Mirrors the role of :func:`shlex.quote` for the Windows shell so the
|
|
sandbox command can pass a single token through :func:`subprocess.run`
|
|
with ``shell=True`` (which on Windows invokes cmd.exe, not /bin/sh).
|
|
"""
|
|
if not s:
|
|
return '""'
|
|
if not any(c in s for c in ' \t\n"&|<>^()'):
|
|
return s
|
|
return '"' + s.replace('"', '\\"') + '"'
|
|
|
|
|
|
def _platform_quote(s: str) -> str:
|
|
"""Quote *s* for the host's default shell.
|
|
|
|
On POSIX, delegates to :func:`shlex.quote` (single-quote wrapping).
|
|
On Windows, uses double-quote wrapping compatible with cmd.exe —
|
|
see :func:`_cmd_quote`. The platform check is read at call time, so
|
|
tests can swap it via ``monkeypatch.setattr(backends, "_is_windows", ...)``
|
|
without mutating :mod:`sys` module state.
|
|
"""
|
|
if _is_windows():
|
|
return _cmd_quote(s)
|
|
return shlex.quote(s)
|
|
|
|
|
|
def _resolve_virtual_mount_path(token: str) -> str | None:
|
|
"""Resolve a virtual mount token to a shell-safe token, or ``None`` when
|
|
*token* is not a registered virtual mount.
|
|
|
|
For ``/skills/...``: walks ``_skills_tier_paths()`` priority (USER →
|
|
GLOBAL → BUILTIN), returning :func:`_platform_quote` of the first tier
|
|
where the path exists. On miss, returns a workspace-relative
|
|
``./skills/<rel>`` form — agent typed a virtual path, so the shell error
|
|
should reference a location they recognise (`USER_SKILLS_DIR` defaults to
|
|
``WORKSPACE_ROOT / "skills"``, which is also where ``MergedSkillsBackend``
|
|
would write a new skill).
|
|
|
|
For ``/memories/...``: single tier (``paths.MEMORIES_DIR``), always
|
|
absolute and :func:`_platform_quote`-wrapped. Memories live outside the
|
|
workspace, so a relative form would point at an unrelated location.
|
|
"""
|
|
rel = _subpath_under_mount(token, "/skills")
|
|
if rel is not None:
|
|
for tier in _skills_tier_paths():
|
|
if tier is None:
|
|
continue
|
|
candidate = Path(tier) / rel
|
|
if candidate.exists():
|
|
return _platform_quote(str(candidate))
|
|
return _platform_quote("./skills/" + rel if rel else "./skills")
|
|
|
|
rel = _subpath_under_mount(token, "/memories")
|
|
if rel is not None:
|
|
return _platform_quote(str(Path(paths.MEMORIES_DIR) / rel))
|
|
|
|
return None
|
|
|
|
|
|
def _guard_bare_absolute(result: str | None) -> str | None:
|
|
"""If *result* is a bare absolute path (no surrounding quotes),
|
|
single-quote it so the post-process regex won't re-rewrite it."""
|
|
if result and result.startswith("/") and result == result.strip("'\""):
|
|
return "'" + result + "'"
|
|
return result
|
|
|
|
|
|
def _rewrite_quoted_path(
|
|
path: str,
|
|
workspace_name: str | None,
|
|
) -> str | None:
|
|
"""Return the shell-quoted replacement for *path* (the decoded
|
|
content of a quoted ``"..."`` or ``'...'`` argument),
|
|
or ``None`` if no rewrite applies.
|
|
"""
|
|
if not path or "://" in path[max(0, len(path) - 10) :]:
|
|
return None
|
|
if not path.startswith("/"):
|
|
return None
|
|
|
|
resolved = _resolve_virtual_mount_path(path)
|
|
if resolved is not None:
|
|
return _guard_bare_absolute(resolved) # already shlex.quoted
|
|
|
|
# Fix hallucinated system absolute paths that reference the workspace.
|
|
if workspace_name:
|
|
for prefix in _SYSTEM_PATH_PREFIXES:
|
|
if path.startswith(prefix):
|
|
marker = f"/{workspace_name}/"
|
|
idx = path.rfind(marker)
|
|
if idx != -1:
|
|
relative = path[idx + len(marker) :]
|
|
return _guard_bare_absolute(
|
|
shlex.quote("./" + relative if relative else ".")
|
|
)
|
|
if path.endswith(f"/{workspace_name}"):
|
|
return _guard_bare_absolute(shlex.quote("."))
|
|
break
|
|
|
|
return None
|
|
|
|
|
|
def convert_virtual_paths_in_command(
|
|
command: str,
|
|
workspace_name: str | None = None,
|
|
) -> str:
|
|
"""Convert virtual paths (starting with ``/``) in commands to relative paths.
|
|
|
|
Also auto-corrects hallucinated system absolute paths that reference the
|
|
workspace directory (e.g. ``/Users/.../myproject/file.py`` → ``./file.py``).
|
|
|
|
Pre-process: quoted arguments whose content resolves to a virtual
|
|
mount (``/skills/...``, ``/memories/...``) or a workspace-prefixed
|
|
system path are rewritten as a single shell token — this fixes #237
|
|
where ``python "/skills/my skill/main.py"`` was truncated at the
|
|
embedded space. Bare quoted ``/...`` paths (e.g. ``echo "/hi"``)
|
|
are left untouched since their semantics are ambiguous.
|
|
After pre-processing, the original regex handles unquoted
|
|
paths and workspace-name correction as before.
|
|
"""
|
|
# Pre-process: rewrite quoted paths whose decoded content starts with /
|
|
command = re.sub(
|
|
r'(["\'])((?:\\.|(?!\1).)*?)\1',
|
|
lambda m: (
|
|
_rewrite_quoted_path(
|
|
re.sub(r"\\(.)", r"\1", m.group(2)),
|
|
workspace_name,
|
|
)
|
|
or m.group(0)
|
|
),
|
|
command,
|
|
)
|
|
|
|
def replace_virtual_path(match: re.Match[str]) -> str:
|
|
path = match.group(0)
|
|
|
|
# Skip content that looks like a URL
|
|
if "://" in command[max(0, match.start() - 10) : match.end() + 10]:
|
|
return path
|
|
|
|
resolved = _resolve_virtual_mount_path(path)
|
|
if resolved is not None:
|
|
return resolved
|
|
|
|
# Fix hallucinated system absolute paths that reference the workspace.
|
|
if workspace_name:
|
|
for prefix in _SYSTEM_PATH_PREFIXES:
|
|
if path.startswith(prefix):
|
|
marker = f"/{workspace_name}/"
|
|
idx = path.rfind(marker)
|
|
if idx != -1:
|
|
relative = path[idx + len(marker) :]
|
|
return "./" + relative if relative else "."
|
|
elif path.endswith(f"/{workspace_name}"):
|
|
return "."
|
|
break # Matched system prefix but no workspace → fall through
|
|
|
|
# Convert virtual path
|
|
if path == "/":
|
|
return "."
|
|
return "." + path
|
|
|
|
# Match pattern: paths starting with / (but not URLs)
|
|
pattern = r'(?<=\s)/[^\s;|&<>\'"`]*|^/[^\s;|&<>\'"`]*'
|
|
converted = re.sub(pattern, replace_virtual_path, command)
|
|
|
|
return converted
|
|
|
|
|
|
class ReadOnlyFilesystemBackend(FilesystemBackend):
|
|
"""
|
|
Read-only filesystem backend.
|
|
|
|
Allows read, ls, grep, glob operations but blocks write, edit, and upload.
|
|
Used for skills directory — agent can read skill definitions but cannot
|
|
modify them.
|
|
"""
|
|
|
|
def write(self, file_path: str, content: str) -> WriteResult:
|
|
return WriteResult(
|
|
error="This directory is read-only. Write operations are not permitted here."
|
|
)
|
|
|
|
def edit(
|
|
self,
|
|
file_path: str,
|
|
old_string: str,
|
|
new_string: str,
|
|
replace_all: bool = False,
|
|
) -> EditResult:
|
|
return EditResult(
|
|
error="This directory is read-only. Edit operations are not permitted here."
|
|
)
|
|
|
|
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
return [
|
|
FileUploadResponse(
|
|
path=file_path,
|
|
error="This directory is read-only. Upload operations are not permitted here.",
|
|
)
|
|
for file_path, _ in files
|
|
]
|
|
|
|
def delete(self, file_path: str) -> DeleteResult:
|
|
return DeleteResult(
|
|
error="This directory is read-only. Delete operations are not permitted here."
|
|
)
|
|
|
|
|
|
class MemoryFilesystemBackend(FilesystemBackend):
|
|
"""Filesystem backend for memory files with structured-write enforcement.
|
|
|
|
Agents may read memory files and edit existing profile notes, but raw file
|
|
creation is blocked so observations are recorded through memory tools.
|
|
"""
|
|
|
|
_RAW_WRITE_ERROR = (
|
|
"Raw writes to /memories are blocked. Edit existing "
|
|
"/memories/profile/... files or use memory tools."
|
|
)
|
|
_RAW_EDIT_ERROR = (
|
|
"Raw edits under /memories are limited to existing "
|
|
"/memories/profile/... files. Use memory tools for observations."
|
|
)
|
|
_RAW_DELETE_ERROR = (
|
|
"Deletes under /memories are blocked. Manage memory files through "
|
|
"memory tools instead."
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
root_dir: str | Path | None = None,
|
|
virtual_mode: bool | None = None,
|
|
max_file_size_mb: int = 10,
|
|
) -> None:
|
|
super().__init__(root_dir, virtual_mode, max_file_size_mb)
|
|
self._profile_write_lock = FileLock(str(self.cwd / ".profile-write.lock"))
|
|
|
|
@staticmethod
|
|
def _is_profile_path(file_path: str) -> bool:
|
|
normalized = posixpath.normpath("/" + file_path.strip().lstrip("/"))
|
|
return normalized == "/profile" or normalized.startswith("/profile/")
|
|
|
|
def write(self, file_path: str, content: str) -> WriteResult:
|
|
return WriteResult(error=self._RAW_WRITE_ERROR)
|
|
|
|
def edit(
|
|
self,
|
|
file_path: str,
|
|
old_string: str,
|
|
new_string: str,
|
|
replace_all: bool = False,
|
|
) -> EditResult:
|
|
if not self._is_profile_path(file_path):
|
|
return EditResult(error=self._RAW_EDIT_ERROR)
|
|
with self._profile_write_lock:
|
|
return super().edit(file_path, old_string, new_string, replace_all)
|
|
|
|
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
return [
|
|
FileUploadResponse(path=file_path, error=self._RAW_WRITE_ERROR)
|
|
for file_path, _ in files
|
|
]
|
|
|
|
def delete(self, file_path: str) -> DeleteResult:
|
|
return DeleteResult(error=self._RAW_DELETE_ERROR)
|
|
|
|
|
|
def build_memory_agent_backend(
|
|
*,
|
|
workspace_dir: str | Path,
|
|
memory_dir: str | Path,
|
|
):
|
|
"""Build the standard memory-agent backend with guarded `/memories/` routing."""
|
|
from deepagents.backends import CompositeBackend
|
|
|
|
return CompositeBackend(
|
|
default=FilesystemBackend(root_dir=str(workspace_dir), virtual_mode=True),
|
|
routes={
|
|
"/memories/": MemoryFilesystemBackend(
|
|
root_dir=str(memory_dir),
|
|
virtual_mode=True,
|
|
)
|
|
},
|
|
)
|
|
|
|
|
|
def build_memory_worker_backend(
|
|
*,
|
|
workspace_dir: str | Path,
|
|
memory_dir: str | Path,
|
|
):
|
|
"""Build the memory-worker backend.
|
|
|
|
Workers may update profile memory through /memories/profile/... and write
|
|
observations through structured tools. The workspace itself is read-only.
|
|
"""
|
|
from deepagents.backends import CompositeBackend
|
|
|
|
return CompositeBackend(
|
|
default=ReadOnlyFilesystemBackend(
|
|
root_dir=str(workspace_dir),
|
|
virtual_mode=True,
|
|
),
|
|
routes={
|
|
"/memories/": MemoryFilesystemBackend(
|
|
root_dir=str(memory_dir),
|
|
virtual_mode=True,
|
|
)
|
|
},
|
|
)
|
|
|
|
|
|
def build_autoskill_agent_backend(
|
|
*,
|
|
memory_dir: str | Path,
|
|
proposals_dir: str | Path,
|
|
sandbox_timeout: int = 300,
|
|
):
|
|
"""Build the AutoSkills backend.
|
|
|
|
AutoSkills has a different security model from ordinary memory
|
|
maintenance: it can read memories and installed skills, write proposal
|
|
folders, and run shell validation from the proposal root.
|
|
"""
|
|
from deepagents.backends import CompositeBackend
|
|
|
|
return CompositeBackend(
|
|
default=AutoskillProposalSandboxBackend(
|
|
root_dir=str(proposals_dir),
|
|
timeout=sandbox_timeout,
|
|
),
|
|
routes={
|
|
"/memories/": ReadOnlyFilesystemBackend(
|
|
root_dir=str(memory_dir),
|
|
virtual_mode=True,
|
|
),
|
|
"/skills/": MergedSkillsBackend(
|
|
primary_dir=str(paths.USER_SKILLS_DIR),
|
|
global_dir=str(paths.GLOBAL_SKILLS_DIR),
|
|
secondary_dir=str(_BUILTIN_SKILLS_DIR),
|
|
writable_primary=False,
|
|
),
|
|
"/autoskill-proposals/": FilesystemBackend(
|
|
root_dir=str(proposals_dir),
|
|
virtual_mode=True,
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
class MergedSkillsBackend(BackendProtocol):
|
|
"""Skills backend that merges up to three skill directories.
|
|
|
|
Priority (high → low):
|
|
1. primary — workspace/skills/ (project-local, writable)
|
|
2. global — ~/.evoscientist/skills/ (user global, read-only)
|
|
3. secondary — EvoScientist/skills/ (built-in, PyPI, read-only)
|
|
|
|
Higher-priority skills override lower-priority skills with the same name.
|
|
All directories share the same virtual path namespace (/skills/).
|
|
Only the workspace tier (primary) allows write and edit operations.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
primary_dir: str,
|
|
secondary_dir: str,
|
|
global_dir: str | None = None,
|
|
writable_primary: bool = True,
|
|
):
|
|
primary_backend = (
|
|
FilesystemBackend if writable_primary else ReadOnlyFilesystemBackend
|
|
)
|
|
self._primary = primary_backend(root_dir=primary_dir, virtual_mode=True)
|
|
self._global = (
|
|
ReadOnlyFilesystemBackend(root_dir=global_dir, virtual_mode=True)
|
|
if global_dir
|
|
else None
|
|
)
|
|
self._secondary = ReadOnlyFilesystemBackend(
|
|
root_dir=secondary_dir, virtual_mode=True
|
|
)
|
|
|
|
def _backends(self):
|
|
"""Yield backends in priority order: primary → global → secondary."""
|
|
yield self._primary
|
|
if self._global:
|
|
yield self._global
|
|
yield self._secondary
|
|
|
|
# -- read: try each tier in priority order --
|
|
|
|
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
for backend in list(self._backends())[:-1]:
|
|
try:
|
|
result = backend.read(file_path, offset, limit)
|
|
if hasattr(result, "error"):
|
|
if result.error is None:
|
|
return result
|
|
elif not str(result).startswith("Error:"):
|
|
return result
|
|
except (ValueError, FileNotFoundError, OSError):
|
|
pass
|
|
return self._secondary.read(file_path, offset, limit)
|
|
|
|
# -- ls: merge all tiers, higher priority wins on name conflicts --
|
|
|
|
def ls(self, path: str = "/") -> LsResult:
|
|
merged: dict = {}
|
|
for backend in reversed(list(self._backends())):
|
|
result = backend.ls(path)
|
|
for item in result.entries or []:
|
|
merged[item["path"]] = item
|
|
return LsResult(entries=sorted(merged.values(), key=lambda x: x["path"]))
|
|
|
|
# -- grep: search all tiers --
|
|
|
|
def grep(
|
|
self, pattern: str, path: str | None = None, glob: str | None = None
|
|
) -> GrepResult:
|
|
matches = []
|
|
for backend in self._backends():
|
|
try:
|
|
result = backend.grep(pattern, path, glob)
|
|
matches.extend(result.matches or [])
|
|
except Exception:
|
|
pass
|
|
return GrepResult(matches=matches)
|
|
|
|
# -- glob: merge all tiers, higher priority wins on name conflicts --
|
|
|
|
def glob(self, pattern: str, path: str = "/") -> GlobResult:
|
|
merged: dict = {}
|
|
for backend in reversed(list(self._backends())):
|
|
try:
|
|
result = backend.glob(pattern, path)
|
|
for item in result.matches or []:
|
|
merged[item["path"]] = item
|
|
except Exception:
|
|
pass
|
|
return GlobResult(matches=sorted(merged.values(), key=lambda x: x["path"]))
|
|
|
|
# -- write / edit: only workspace/skills/ (primary) is writable --
|
|
|
|
def write(self, file_path: str, content: str) -> WriteResult:
|
|
return self._primary.write(file_path, content)
|
|
|
|
def edit(
|
|
self,
|
|
file_path: str,
|
|
old_string: str,
|
|
new_string: str,
|
|
replace_all: bool = False,
|
|
) -> EditResult:
|
|
return self._primary.edit(file_path, old_string, new_string, replace_all)
|
|
|
|
# -- download / upload --
|
|
|
|
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
"""Download files, trying each tier in priority order."""
|
|
backends = list(self._backends())
|
|
responses: list[FileDownloadResponse] = []
|
|
for path in paths:
|
|
resp = backends[-1].download_files([path])[0]
|
|
for backend in backends[:-1]:
|
|
candidate = backend.download_files([path])[0]
|
|
if candidate.error is None:
|
|
resp = candidate
|
|
break
|
|
responses.append(resp)
|
|
return responses
|
|
|
|
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
return self._primary.upload_files(files)
|
|
|
|
|
|
def prepare_sandbox_command(
|
|
command: str,
|
|
cwd: str | Path,
|
|
*,
|
|
virtual_mode: bool = True,
|
|
dangerous: bool = False,
|
|
guard_dangerous: bool = False,
|
|
) -> tuple[str, str | None]:
|
|
"""Normalize workspace paths in ``command`` and validate it for the sandbox.
|
|
|
|
Shared by :meth:`CustomSandboxBackend.execute` and the background-process tools so
|
|
both enforce *identical* workspace-path rewriting (so virtual ``/`` paths resolve to
|
|
the workspace, not the host root) and the same command validation.
|
|
|
|
Returns ``(prepared_command, error)``: ``error`` is a message string when the command
|
|
is rejected (the caller must NOT run it), otherwise ``None``.
|
|
|
|
``guard_dangerous`` (see :func:`check_dangerous_command`) does not see inside an SSH
|
|
remote payload: a dangerous pipe *inside* a quoted ``ssh host '...'`` argument is not
|
|
detected, because the quoted payload is a single opaque token. Piping *into* ``ssh``
|
|
itself (e.g. ``cat secret | ssh host x``) is detected — the check runs on the original,
|
|
unmasked command so the SSH-masking done below (which also replaces the literal ``ssh``
|
|
token) does not blind it.
|
|
"""
|
|
original_command = command
|
|
|
|
ssh_error = _validate_ssh_remote_command_format(command)
|
|
if ssh_error:
|
|
return command, ssh_error
|
|
|
|
command, ssh_replacements = _mask_ssh_remote_commands(command)
|
|
|
|
cwd_str = str(cwd).rstrip("/")
|
|
# Replace literal workspace-root absolute paths with ./ after SSH masking so
|
|
# remote paths that happen to contain the local cwd are preserved, and before
|
|
# validation so local workspace paths are sanitized before the system-path
|
|
# check fires. Skipped in dangerous mode: there is no virtual workspace, the
|
|
# agent uses real absolute paths, and rewriting would corrupt any argument
|
|
# (echo text, grep/git pattern) that merely contains the cwd string.
|
|
if not dangerous:
|
|
ws = cwd_str + "/"
|
|
if ws in command:
|
|
command = command.replace(ws, "./")
|
|
if virtual_mode:
|
|
command = convert_virtual_paths_in_command(
|
|
command=command,
|
|
workspace_name=Path(cwd_str).name,
|
|
)
|
|
# Skills/memory dirs must be allowlisted: the workspace-literal replace above runs
|
|
# before the resolver, so any absolute path it later injects reaches validate unstripped.
|
|
allow_prefixes = (
|
|
str(paths.USER_SKILLS_DIR),
|
|
str(paths.GLOBAL_SKILLS_DIR),
|
|
str(paths.MEMORIES_DIR),
|
|
str(_BUILTIN_SKILLS_DIR),
|
|
)
|
|
error = validate_command(
|
|
command, allow_prefixes=allow_prefixes, dangerous=dangerous
|
|
)
|
|
if error:
|
|
return command, error
|
|
|
|
# No interactive approval is reachable here (unattended main agent, or an
|
|
# async sub-agent on a remote thread), so refuse the narrow dangerous set
|
|
# with a reason the agent can act on rather than running it blind.
|
|
if guard_dangerous and not dangerous:
|
|
dangerous_reason = check_dangerous_command(original_command)
|
|
if dangerous_reason:
|
|
return _restore_spans(command, ssh_replacements), (
|
|
f"Command blocked: {dangerous_reason}. "
|
|
f"Rewrite it to avoid that, or request approval from the user "
|
|
f"(the orchestrator can re-issue it after approval)."
|
|
)
|
|
|
|
return _restore_spans(command, ssh_replacements), None
|
|
|
|
|
|
class CustomSandboxBackend(LocalShellBackend):
|
|
"""
|
|
Custom sandbox backend - inherits LocalShellBackend with added safety.
|
|
|
|
Features:
|
|
- Inherits all file operations (ls, read, write, edit, grep, glob)
|
|
- Inherits shell command execution with output truncation and timeout
|
|
- Adds command validation to prevent directory traversal and dangerous operations
|
|
- Adds path sanitization to auto-correct common LLM path mistakes
|
|
- Compatible with LangGraph checkpointer (no thread locks)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
root_dir: str = ".",
|
|
*,
|
|
virtual_mode: bool = True,
|
|
timeout: int = 300,
|
|
max_output_bytes: int = 100_000,
|
|
env: dict[str, str] | None = None,
|
|
inherit_env: bool = True,
|
|
dangerous: bool = False,
|
|
guard_dangerous: bool = False,
|
|
refuse_delete: bool = False,
|
|
):
|
|
"""
|
|
Initialize custom sandbox backend.
|
|
|
|
Args:
|
|
root_dir: File system root directory
|
|
virtual_mode: Whether to enable virtual path mode
|
|
timeout: Command execution timeout in seconds
|
|
max_output_bytes: Max output size before truncation (default 100KB)
|
|
env: Extra environment variables for subprocess
|
|
inherit_env: Whether to inherit parent process env (default True)
|
|
dangerous: Real-filesystem mode — the agent operates on real absolute
|
|
paths anywhere on disk (no workspace confinement). Forces
|
|
``virtual_mode=False`` and relaxes path validation while keeping
|
|
the privileged-command blocklist. Defaults to False.
|
|
guard_dangerous: Refuse the narrow dangerous-command set (see
|
|
:func:`check_dangerous_command`) outright, for contexts where
|
|
no interactive approval is reachable (unattended auto-approve
|
|
runs, async sub-agents). Bypassed when ``dangerous=True``.
|
|
Defaults to False.
|
|
refuse_delete: Refuse the recursive ``delete`` FS tool outright,
|
|
relaying an approval request to the orchestrator. Used for async
|
|
research sub-agents (writing / data-analysis) that have no
|
|
interactive approval path. Bypassed when ``dangerous=True``.
|
|
Defaults to False.
|
|
"""
|
|
self._dangerous = dangerous
|
|
self._guard_dangerous = guard_dangerous
|
|
self._refuse_delete = refuse_delete
|
|
if dangerous:
|
|
# Real paths require the legacy (non-virtual) resolution path so the
|
|
# parent backend returns absolute paths as-is.
|
|
virtual_mode = False
|
|
super().__init__(
|
|
root_dir=root_dir,
|
|
virtual_mode=virtual_mode,
|
|
timeout=timeout,
|
|
max_output_bytes=max_output_bytes,
|
|
env=env,
|
|
inherit_env=inherit_env,
|
|
)
|
|
# Override parent's "local-" prefix with our own
|
|
self._sandbox_id = f"evosci-{uuid.uuid4().hex[:8]}"
|
|
# Ensure working directory exists
|
|
os.makedirs(str(self.cwd), exist_ok=True)
|
|
|
|
def _resolve_path(self, key: str) -> Path:
|
|
"""Resolve path with sanitization to prevent nested directories.
|
|
|
|
Intercepts all file operations (read, write, edit, ls, grep, glob).
|
|
Auto-corrects common LLM path mistakes instead of crashing:
|
|
1. /Users/.../<cwd>/file.py → /file.py (full cwd match — safest)
|
|
2. /<ws_name>/file.py → /file.py
|
|
3. /Users/name/.../<ws_name>/f → /f (strip at LAST <ws_name>/)
|
|
4. /Users/name/file.py → /file.py (keep basename)
|
|
|
|
In dangerous (real-filesystem) mode, skip all rewriting and let the
|
|
parent resolve real absolute paths as-is.
|
|
"""
|
|
if self._dangerous:
|
|
return super()._resolve_path(key)
|
|
|
|
cwd_str = str(self.cwd).rstrip("/")
|
|
ws_name = Path(cwd_str).name # e.g. "workspace", "my-project"
|
|
|
|
# Prefer the full cwd match so a parent path that happens to contain
|
|
# "/<ws_name>/" (e.g. cwd = /Users/u/workspace/.../workspace) doesn't
|
|
# confuse the basename-based fallback below.
|
|
if key == cwd_str:
|
|
return super()._resolve_path("/")
|
|
if key.startswith(cwd_str + "/"):
|
|
return super()._resolve_path("/" + key[len(cwd_str) + 1 :])
|
|
|
|
# Auto-strip /<ws_name>/ prefix to prevent nesting
|
|
ws_prefix = f"/{ws_name}/"
|
|
if key.startswith(ws_prefix):
|
|
key = key[len(ws_prefix) - 1 :] # "/<ws>/main.py" → "/main.py"
|
|
elif key == f"/{ws_name}":
|
|
key = "/"
|
|
|
|
# Auto-correct system absolute paths
|
|
for prefix in _SYSTEM_PATH_PREFIXES:
|
|
if key.startswith(prefix):
|
|
# rfind, not find: the cwd's parent path may itself contain
|
|
# "/<ws_name>/" as a substring, and we want the boundary
|
|
# nearest the file — the workspace mount.
|
|
idx = key.rfind(ws_prefix)
|
|
if idx != -1:
|
|
key = "/" + key[idx + len(ws_prefix) :]
|
|
elif key.endswith(f"/{ws_name}"):
|
|
key = "/"
|
|
else:
|
|
# Fall back to basename
|
|
key = "/" + Path(key).name
|
|
break
|
|
|
|
return super()._resolve_path(key)
|
|
|
|
_DELETE_APPROVAL_ERROR = (
|
|
"Delete blocked: needs approval. Report it to the orchestrator, which "
|
|
"can re-issue it after approval."
|
|
)
|
|
|
|
def delete(self, file_path: str) -> DeleteResult:
|
|
"""Refuse ``delete`` for guarded async sub-agents (no approval path).
|
|
|
|
No ``adelete`` override is needed: the inherited ``BackendProtocol.adelete``
|
|
runs ``asyncio.to_thread(self.delete, ...)``, so async sub-agents reach
|
|
this refusal too.
|
|
"""
|
|
if self._refuse_delete and not self._dangerous:
|
|
return DeleteResult(error=self._DELETE_APPROVAL_ERROR)
|
|
return super().delete(file_path)
|
|
|
|
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
"""
|
|
Execute shell command in sandbox environment.
|
|
|
|
Commands are validated before execution to prevent:
|
|
- Directory traversal (../)
|
|
- Access to paths outside workspace
|
|
- Dangerous system commands
|
|
|
|
The validated command is handed to the owned process runner so
|
|
cancelling an agent turn can terminate the complete process tree.
|
|
"""
|
|
# Preserve LocalShellBackend's public validation contract. This
|
|
# override cannot delegate execution to the base implementation because
|
|
# it must retain the Popen handle for cancellation, so validate before
|
|
# command preparation and process launch instead.
|
|
if not command or not isinstance(command, str):
|
|
return ExecuteResponse(
|
|
output="Error: Command must be a non-empty string.",
|
|
exit_code=1,
|
|
truncated=False,
|
|
)
|
|
|
|
command, error = prepare_sandbox_command(
|
|
command,
|
|
self.cwd,
|
|
virtual_mode=self.virtual_mode,
|
|
dangerous=self._dangerous,
|
|
guard_dangerous=self._guard_dangerous,
|
|
)
|
|
if error:
|
|
return ExecuteResponse(output=error, exit_code=1, truncated=False)
|
|
|
|
return self._execute_prepared_command(command, timeout=timeout)
|
|
|
|
def _execute_prepared_command(
|
|
self,
|
|
command: str,
|
|
*,
|
|
timeout: int | None = None,
|
|
) -> ExecuteResponse:
|
|
"""Execute an already validated command in an owned process group."""
|
|
|
|
effective_timeout = timeout if timeout is not None else self._default_timeout
|
|
if effective_timeout <= 0:
|
|
msg = f"timeout must be positive, got {effective_timeout}"
|
|
raise ValueError(msg)
|
|
|
|
cancel_event = current_cancel_event()
|
|
if cancel_event is not None and cancel_event.is_set():
|
|
return ExecuteResponse(
|
|
output="Command cancelled before execution.",
|
|
exit_code=130,
|
|
truncated=False,
|
|
)
|
|
|
|
process: subprocess.Popen[str] | None = None
|
|
termination_reason: str | None = None
|
|
output_abandoned = False
|
|
try:
|
|
process_options: dict[str, object] = {}
|
|
if os.name == "nt":
|
|
process_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
else:
|
|
process_options["start_new_session"] = True
|
|
|
|
process = subprocess.Popen(
|
|
command,
|
|
shell=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
stdin=subprocess.DEVNULL,
|
|
text=True,
|
|
env=self._env,
|
|
cwd=str(self.cwd),
|
|
**process_options,
|
|
)
|
|
_register_shell_process(cancel_event, process)
|
|
deadline = time.monotonic() + effective_timeout
|
|
drain_deadline: float | None = None
|
|
|
|
while True:
|
|
now = time.monotonic()
|
|
if (
|
|
termination_reason is None
|
|
and cancel_event is not None
|
|
and cancel_event.is_set()
|
|
):
|
|
termination_reason = "cancelled"
|
|
_terminate_process_tree(process)
|
|
drain_deadline = now + _PROCESS_DRAIN_GRACE_SECONDS
|
|
elif termination_reason is None and now >= deadline:
|
|
termination_reason = "timed_out"
|
|
_terminate_process_tree(process)
|
|
drain_deadline = now + _PROCESS_DRAIN_GRACE_SECONDS
|
|
|
|
if drain_deadline is not None and now >= drain_deadline:
|
|
_stop_collecting_process_output(process)
|
|
stdout = stderr = ""
|
|
output_abandoned = True
|
|
break
|
|
|
|
communicate_deadline = (
|
|
drain_deadline if drain_deadline is not None else deadline
|
|
)
|
|
try:
|
|
stdout, stderr = process.communicate(
|
|
timeout=max(
|
|
0.01,
|
|
min(0.1, communicate_deadline - time.monotonic()),
|
|
)
|
|
)
|
|
break
|
|
except subprocess.TimeoutExpired:
|
|
continue
|
|
|
|
if termination_reason == "timed_out":
|
|
if timeout is not None:
|
|
timeout_output = (
|
|
"Error: Command timed out after "
|
|
f"{effective_timeout} seconds (custom timeout). The command "
|
|
"may be stuck or require more time."
|
|
)
|
|
else:
|
|
timeout_output = (
|
|
f"Error: Command timed out after {effective_timeout} seconds. "
|
|
"For long-running commands, re-run using the timeout parameter."
|
|
)
|
|
response = ExecuteResponse(
|
|
output=timeout_output,
|
|
exit_code=124,
|
|
truncated=output_abandoned,
|
|
)
|
|
elif termination_reason == "cancelled" or (
|
|
cancel_event is not None and cancel_event.is_set()
|
|
):
|
|
response = ExecuteResponse(
|
|
output="Command cancelled.",
|
|
exit_code=130,
|
|
truncated=output_abandoned,
|
|
)
|
|
else:
|
|
output_parts = []
|
|
if stdout:
|
|
output_parts.append(stdout)
|
|
if stderr:
|
|
stderr_lines = stderr.strip().split("\n")
|
|
output_parts.extend(f"[stderr] {line}" for line in stderr_lines)
|
|
output = "\n".join(output_parts) if output_parts else "<no output>"
|
|
|
|
truncated = False
|
|
if len(output) > self._max_output_bytes:
|
|
output = output[: self._max_output_bytes]
|
|
output += (
|
|
f"\n\n... Output truncated at {self._max_output_bytes} bytes."
|
|
)
|
|
truncated = True
|
|
if process.returncode != 0:
|
|
output = f"{output.rstrip()}\n\nExit code: {process.returncode}"
|
|
response = ExecuteResponse(
|
|
output=output,
|
|
exit_code=process.returncode,
|
|
truncated=truncated,
|
|
)
|
|
except Exception as exc:
|
|
if process is not None:
|
|
_terminate_process_tree(process)
|
|
response = ExecuteResponse(
|
|
output=f"Error executing command ({type(exc).__name__}): {exc}",
|
|
exit_code=1,
|
|
truncated=False,
|
|
)
|
|
finally:
|
|
if process is not None:
|
|
_unregister_shell_process(cancel_event, process)
|
|
|
|
# Enhance timeout errors with actionable recovery guidance
|
|
if response.exit_code == 124:
|
|
cmd_words = command.split()
|
|
grep_hint = cmd_words[0] if cmd_words else "process"
|
|
# In dangerous mode `/` is the host root; use a workspace-relative
|
|
# log path so the suggested command doesn't fail or write to `/`.
|
|
output_log = "./output.log" if self._dangerous else "/output.log"
|
|
bg_cmd = f'{command} > {output_log} 2>&1 & echo "PID: $!"'
|
|
response = ExecuteResponse(
|
|
output=(
|
|
f"{response.output}\n\n"
|
|
f"Recovery — pick one:\n"
|
|
f" 1. Needs more time? Re-run with a larger timeout (up to 3600s): "
|
|
f"execute(command=..., timeout=600)\n"
|
|
f" 2. Runs indefinitely? Run it in the background and keep the PID:\n"
|
|
f" {bg_cmd}\n"
|
|
f" Check: ps -p <PID> (or: ps aux | grep {grep_hint}) · "
|
|
f"Read: cat {output_log} · Stop: kill <PID>"
|
|
),
|
|
exit_code=response.exit_code,
|
|
truncated=response.truncated,
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
class AutoskillProposalSandboxBackend(CustomSandboxBackend):
|
|
"""Shell backend rooted at the autoskill proposal directory.
|
|
|
|
File-tool writes through this backend are blocked; proposal writes go
|
|
through the `/autoskill-proposals/` route. Shell commands run with cwd set
|
|
to the proposal root so validation commands can inspect generated skill
|
|
folders without executing in the user's project workspace.
|
|
"""
|
|
|
|
_RAW_WRITE_ERROR = (
|
|
"Raw workspace writes are blocked for AutoSkills. Write proposal files "
|
|
"under /autoskill-proposals/<skill-name>/."
|
|
)
|
|
|
|
@staticmethod
|
|
def _rewrite_autoskill_mount(command: str) -> str:
|
|
return re.sub(
|
|
r"(^|[\s'\"(=<>])/autoskill-proposals(?=/|$|[\s'\";|&)])",
|
|
r"\1.",
|
|
command,
|
|
)
|
|
|
|
def write(self, file_path: str, content: str) -> WriteResult:
|
|
return WriteResult(error=self._RAW_WRITE_ERROR)
|
|
|
|
def edit(
|
|
self,
|
|
file_path: str,
|
|
old_string: str,
|
|
new_string: str,
|
|
replace_all: bool = False,
|
|
) -> EditResult:
|
|
return EditResult(error=self._RAW_WRITE_ERROR)
|
|
|
|
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
return [
|
|
FileUploadResponse(path=file_path, error=self._RAW_WRITE_ERROR)
|
|
for file_path, _ in files
|
|
]
|
|
|
|
def delete(self, file_path: str) -> DeleteResult:
|
|
return DeleteResult(
|
|
error="Deletes are blocked for AutoSkills. Manage proposal files "
|
|
"under /autoskill-proposals/ instead."
|
|
)
|
|
|
|
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
return super().execute(
|
|
self._rewrite_autoskill_mount(command),
|
|
timeout=timeout,
|
|
)
|