diff --git a/src/rlm/skills/__init__.py b/src/rlm/skills/__init__.py index 53afde1..849523b 100644 --- a/src/rlm/skills/__init__.py +++ b/src/rlm/skills/__init__.py @@ -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, } diff --git a/src/rlm/skills/bash.py b/src/rlm/skills/bash.py new file mode 100644 index 0000000..3ec8858 --- /dev/null +++ b/src/rlm/skills/bash.py @@ -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. +""" + +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()) diff --git a/tests/test_builtin_skills.py b/tests/test_builtin_skills.py index 4bf3f28..f38f0f8 100644 --- a/tests/test_builtin_skills.py +++ b/tests/test_builtin_skills.py @@ -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 @@ -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