Skip to content

Commit fea3321

Browse files
jholt96Copilot
andauthored
fix(bash tool) implement proper command execution to not escape the sandbox (#1493)
It is currently possible for users to prompt an agent using skills to escape srt and run bash commands without file or network restriction. This change enforces proper bash command where it is passed as a single argument without shell interpolation --------- Signed-off-by: Josh Holt <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 9f102b8 commit fea3321

File tree

2 files changed

+43
-4
lines changed

2 files changed

+43
-4
lines changed

python/packages/kagent-skills/src/kagent/skills/shell.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,12 @@ async def execute_command(
133133
env["PATH"] = f"{bash_venv_bin}:{env.get('PATH', '')}"
134134
env["VIRTUAL_ENV"] = bash_venv_path
135135

136-
sandboxed_command = f'srt "{command}"'
137-
138136
try:
139-
process = await asyncio.create_subprocess_shell(
140-
sandboxed_command,
137+
process = await asyncio.create_subprocess_exec(
138+
"srt",
139+
"sh",
140+
"-c",
141+
command,
141142
stdout=asyncio.subprocess.PIPE,
142143
stderr=asyncio.subprocess.PIPE,
143144
cwd=working_dir,

python/packages/kagent-skills/src/kagent/tests/unittests/test_skill_execution.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import asyncio
12
import json
23
import shutil
34
import tempfile
45
import textwrap
56
from pathlib import Path
7+
from unittest.mock import AsyncMock, MagicMock, patch
68

79
import pytest
810

@@ -121,6 +123,42 @@ async def test_skill_core_logic(skill_test_env: Path):
121123
assert json.loads(json_content_str) == expected_data
122124

123125

126+
@pytest.mark.asyncio
127+
async def test_execute_command_no_shell_injection(tmp_path):
128+
"""
129+
Verifies that shell metacharacters in the command are not interpreted by an
130+
outer shell. The command must be passed as a single argument to srt, not
131+
parsed by /bin/sh, so injection payloads cannot escape the sandbox.
132+
"""
133+
captured = {}
134+
135+
async def mock_exec(*args, **kwargs):
136+
captured["args"] = args
137+
mock_process = MagicMock()
138+
mock_process.communicate = AsyncMock(return_value=(b"ok", b""))
139+
mock_process.returncode = 0
140+
return mock_process
141+
142+
injection_payload = 'ls"; cat /etc/passwd; echo "pwned'
143+
144+
with (
145+
patch("asyncio.create_subprocess_shell") as mock_shell,
146+
patch("asyncio.create_subprocess_exec", side_effect=mock_exec),
147+
):
148+
await execute_command(injection_payload, working_dir=tmp_path)
149+
150+
# Invariant 1: create_subprocess_shell must never be used.
151+
assert not mock_shell.called
152+
153+
# Invariant 2: The entire payload must arrive as a single argument to srt, never split by a shell.
154+
args = captured["args"]
155+
# The first argument should still be the sandbox runner.
156+
assert args[0] == "srt"
157+
# The injection payload must appear exactly once as its own argument.
158+
assert injection_payload in args
159+
assert list(args).count(injection_payload) == 1
160+
161+
124162
def test_skill_discovery_and_loading(skill_test_env: Path):
125163
"""
126164
Tests the core logic of discovering a skill and loading its instructions.

0 commit comments

Comments
 (0)