Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/rlm/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# Built-in skill name -> local module, or ``None`` for a supervisor-owned skill.
_BUILTIN_SKILLS: dict[str, str | None] = {
"bash": "rlm.skills.bash",
"edit": "rlm.skills.edit",
"search": None,
}
Expand Down
53 changes: 53 additions & 0 deletions src/rlm/skills/bash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Built-in ``bash`` skill — run a shell command from the REPL.

Enabled via ``RLM_SKILLS``; pre-imported into the IPython kernel so the agent calls
``await bash(command="...")`` (or ``await bash("...")``). One command per call, a fresh
``bash -c`` subshell, with the same git-history guard as shell tool paths.
"""
Comment thread
cursor[bot] marked this conversation as resolved.

from __future__ import annotations

import asyncio
import os
import subprocess

from rlm.tools.git_block import find_blocked_command, refusal


def _default_timeout() -> int:
raw = os.environ.get("RLM_EXEC_TIMEOUT", "")
return int(raw) if raw.isdigit() and int(raw) > 0 else 300


def _run_bash(command: str, timeout: int) -> str:
"""Execute via ``bash -c`` (never /bin/sh) and return combined output."""
blocked = find_blocked_command(command)
if blocked:
return refusal(blocked)
try:
proc = subprocess.run(
["bash", "-c", command],
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return f"Error: command timed out after {timeout}s"
out = proc.stdout + (("\n" + proc.stderr) if proc.stderr else "")
if proc.returncode != 0:
out += f"\n[exit code {proc.returncode}]"
return out.strip() or "(no output)"


async def run(command: str, timeout: int | None = None) -> str:
"""Run a shell command and return its combined output.

Args:
command: The shell command to run (executed with ``bash -c``).
timeout: Seconds before the command is killed (default: the harness
exec timeout via ``RLM_EXEC_TIMEOUT``, else 300).

Returns:
stdout and stderr of the command (with the exit code when nonzero).
"""
return await asyncio.to_thread(_run_bash, command, timeout or _default_timeout())
39 changes: 39 additions & 0 deletions tests/test_builtin_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from rlm.engine import RLMEngine
from rlm.skills import available_builtin_skills, enable_builtin_skills
from rlm.skills.bash import run as bash
from rlm.skills.edit import run as edit
from rlm.skills.search import format_results
from rlm.skills.search import run as run_search
Expand Down Expand Up @@ -155,3 +156,41 @@ async def post(self, url, **kwargs):
assert secret not in source
meta = json.loads((session.dir / "meta.json").read_text())
assert meta["programmatic_tool_call_stats"]["by_tool_python"] == {"search": 1}


async def test_bash_returns_output():
assert await bash("echo hello") == "hello"


async def test_bash_runs_real_bash_not_sh():
# process substitution is a bashism that /bin/sh (dash) rejects
out = await bash("cat <(echo bashism-works)")
assert "bashism-works" in out


async def test_bash_nonzero_exit_reported():
out = await bash("exit 3")
assert "[exit code 3]" in out


async def test_bash_combines_stderr():
out = await bash("echo out && echo err >&2")
assert "out" in out and "err" in out


async def test_bash_skill_enforces_git_history_guard():
out = await bash("git log --all --oneline")
assert "refused" in out.lower() or "--all" in out
assert "commit" not in out.splitlines()[0].lower()


async def test_bash_skill_timeout_reports_error():
out = await bash("sleep 5", timeout=1)
assert "timed out" in out


def test_enable_bash_skill_writes_stub(tmp_path):
enabled = enable_builtin_skills(["bash"], tmp_path)
assert enabled == ["bash"]
stub = (tmp_path / "bash.py").read_text()
assert "from rlm.skills.bash import run" in stub
Loading