From ad0e8d731afb75861700af1ffec4bb30532662d1 Mon Sep 17 00:00:00 2001 From: Namraa Patel Date: Sat, 17 Oct 2026 14:38:46 +0530 Subject: [PATCH 1/3] Fix stateless shell tools inheriting host stdin --- .../agent_framework_tools/shell/_docker.py | 1 + .../agent_framework_tools/shell/_executor.py | 1 + .../tools/tests/test_docker_shell_tool.py | 26 ++++++++++++ .../tools/tests/test_local_shell_tool.py | 41 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/python/packages/tools/agent_framework_tools/shell/_docker.py b/python/packages/tools/agent_framework_tools/shell/_docker.py index a5ad3814a1..20da2b78da 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 446d227ae1..e12ab7da31 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 932fdb39bb..bb7382c783 100644 --- a/python/packages/tools/tests/test_docker_shell_tool.py +++ b/python/packages/tools/tests/test_docker_shell_tool.py @@ -614,3 +614,29 @@ 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 f9dcbdf172..11e61df2de 100644 --- a/python/packages/tools/tests/test_local_shell_tool.py +++ b/python/packages/tools/tests/test_local_shell_tool.py @@ -846,3 +846,44 @@ 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 + """ + import os + + 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() + try: + os.write(w, b"host stdin data that must not be read by the tool\n") + os.close(w) + + # 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. + 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: + os.close(r) From fd76aaa311819b572c75c145e43db991def72fb8 Mon Sep 17 00:00:00 2001 From: Namraa Patel Date: Sat, 17 Oct 2026 17:13:02 +0530 Subject: [PATCH 2/3] test: fix stateless shell stdin regression coverage --- .../tools/tests/test_local_shell_tool.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/python/packages/tools/tests/test_local_shell_tool.py b/python/packages/tools/tests/test_local_shell_tool.py index 11e61df2de..24ecbaaeb3 100644 --- a/python/packages/tools/tests/test_local_shell_tool.py +++ b/python/packages/tools/tests/test_local_shell_tool.py @@ -860,16 +860,21 @@ async def test_stateless_does_not_inherit_host_stdin() -> None: 1. The command gets EOF immediately (not the pipe's data) 2. The pipe's data is still unread by the host """ - import os - 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") - os.close(w) + # 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. @@ -881,9 +886,15 @@ async def test_stateless_does_not_inherit_host_stdin() -> None: 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) From adef3a626b9de2fb570e4c7c9d36f3b5534ef35f Mon Sep 17 00:00:00 2001 From: Namraa Patel Date: Tue, 20 Oct 2026 16:55:43 +0530 Subject: [PATCH 3/3] style: format Docker shell regression test --- python/packages/tools/tests/test_docker_shell_tool.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/packages/tools/tests/test_docker_shell_tool.py b/python/packages/tools/tests/test_docker_shell_tool.py index bb7382c783..58a145a889 100644 --- a/python/packages/tools/tests/test_docker_shell_tool.py +++ b/python/packages/tools/tests/test_docker_shell_tool.py @@ -637,6 +637,4 @@ async def test_run_stateless_sets_stdin_to_devnull() -> None: 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}" - ) + assert kwargs.get("stdin") == asyncio.subprocess.DEVNULL, f"Expected stdin=DEVNULL, got {kwargs.get('stdin')!r}"