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
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ async def _run_stateless(self, command: str, *, timeout: float | None) -> ShellR
started = time.monotonic()
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ async def run_stateless(
started = time.monotonic()
proc = await asyncio.create_subprocess_exec(
*full_argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workdir,
Expand Down
24 changes: 24 additions & 0 deletions python/packages/tools/tests/test_docker_shell_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,27 @@ async def test_docker_no_network_by_default():
# busybox wget against a host that should be unreachable with --network none
r = await shell.run("wget -q -T 2 -O- http://example.com || echo NOACCESS")
assert "NOACCESS" in r.stdout


async def test_run_stateless_sets_stdin_to_devnull() -> None:
"""Regression: stateless Docker spawns must not inherit host stdin.

Without stdin=DEVNULL, `docker run -i` forwards the host's stdin into the
container. Combined with model-chosen commands that read stdin (e.g., `cat`),
this causes the same input theft/hang bug as the local tool.

This test asserts the spawn is given stdin=DEVNULL by inspecting the
asyncio.create_subprocess_exec call.
"""
tool = DockerShellTool(mode="stateless", image="alpine:3", shell="sh")
proc = _FakeProcess(returncode=0, communicate_results=[(b"", b"")])

with patch(
"agent_framework_tools.shell._docker.asyncio.create_subprocess_exec",
AsyncMock(return_value=proc),
) as create_proc:
await tool._run_stateless("echo hi", timeout=5.0)

assert create_proc.await_args is not None
kwargs = create_proc.await_args.kwargs
assert kwargs.get("stdin") == asyncio.subprocess.DEVNULL, f"Expected stdin=DEVNULL, got {kwargs.get('stdin')!r}"
52 changes: 52 additions & 0 deletions python/packages/tools/tests/test_local_shell_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,3 +846,55 @@ async def test_persistent_confines_workdir_by_default_powershell(tmp_path: os.Pa
await tool.run(f"Set-Location -LiteralPath '{subdir}'")
pwd = await tool.run("(Get-Location).Path")
assert os.path.realpath(pwd.stdout.strip()) == os.path.realpath(str(tmp_path))


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX pipe stdin test")
async def test_stateless_does_not_inherit_host_stdin() -> None:
"""Regression: stateless commands must not inherit the host's stdin.

Without stdin=DEVNULL, a model-chosen command like `cat` would read from
the agent host's stdin. In a CLI agent, that's where the user types approval
prompts, so `cat` would steal input and cause either theft or a hang.

This test runs a real `cat` against a real pipe as the host's stdin and asserts:
1. The command gets EOF immediately (not the pipe's data)
2. The pipe's data is still unread by the host
"""
tool = LocalShellTool(mode="stateless", approval_mode="never_require", acknowledge_unsafe=True)

# Create a pipe and write data to it: this simulates a host that has
# input queued on stdin (e.g., a CLI agent with pre-filled approval).
r, w = os.pipe()
# Save the original stdin so we can restore it in finally
original_stdin = os.dup(0)
try:
os.write(w, b"host stdin data that must not be read by the tool\n")
# Keep write end open while executing so cat doesn't get EOF immediately
# if it inherits the pipe. Only close it after subprocess completes.

# Redirect file descriptor 0 (stdin) to the pipe's read end.
# This makes the pipe the host process's stdin.
os.dup2(r, 0)

# Run `cat` through the tool. Without the fix, `cat` would read from
# the inherited stdin (the pipe) and block until EOF.
result = await tool.run("cat", timeout=1.0)

# The command should exit with code 0 and produce no output (got EOF).
assert result.exit_code == 0, f"cat exited with {result.exit_code}"
assert result.stdout == "", f"cat unexpectedly read stdin: {result.stdout!r}"
assert result.timed_out is False, "cat hung waiting for stdin"

# Verify the pipe's data is still unread by the host.
# Since stdin is still redirected to the pipe, we need to restore it first.
os.dup2(original_stdin, 0)
remaining = os.read(r, 1024)
assert remaining == b"host stdin data that must not be read by the tool\n", (
f"Host stdin was consumed: {remaining!r}"
)
finally:
# Restore original stdin even if execution or assertions fail
os.dup2(original_stdin, 0)
os.close(original_stdin)
os.close(r)
os.close(w)
Loading