diff --git a/python/packages/tools/agent_framework_tools/shell/_docker.py b/python/packages/tools/agent_framework_tools/shell/_docker.py index a5ad3814a15..20da2b78da0 100644 --- a/python/packages/tools/agent_framework_tools/shell/_docker.py +++ b/python/packages/tools/agent_framework_tools/shell/_docker.py @@ -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, ) diff --git a/python/packages/tools/agent_framework_tools/shell/_executor.py b/python/packages/tools/agent_framework_tools/shell/_executor.py index 446d227ae1c..e12ab7da317 100644 --- a/python/packages/tools/agent_framework_tools/shell/_executor.py +++ b/python/packages/tools/agent_framework_tools/shell/_executor.py @@ -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, diff --git a/python/packages/tools/tests/test_docker_shell_tool.py b/python/packages/tools/tests/test_docker_shell_tool.py index 932fdb39bb2..58a145a8894 100644 --- a/python/packages/tools/tests/test_docker_shell_tool.py +++ b/python/packages/tools/tests/test_docker_shell_tool.py @@ -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}" diff --git a/python/packages/tools/tests/test_local_shell_tool.py b/python/packages/tools/tests/test_local_shell_tool.py index f9dcbdf172c..24ecbaaeb3b 100644 --- a/python/packages/tools/tests/test_local_shell_tool.py +++ b/python/packages/tools/tests/test_local_shell_tool.py @@ -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)