fix(acp): shield the JSON-RPC stdin from every child process - #74243
fix(acp): shield the JSON-RPC stdin from every child process#74243lxman wants to merge 3 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the defense-in-depth ACP fix. The premise remains present on current main: tools/environments/local.py:898 calls subprocess.run without stdin=, while ACP starts its stdio server from acp_adapter/entry.py:265.
Problems
acp_adapter/entry.py:250in commitd565b67529c947d65ee3615ad003ec5eac3bf752ignoresSetStdHandle's BOOL result. A failed call leaves the Windows process-wide standard-input handle unverified, yet the function continues and logs shielding as successful.- The added probe verifies its own fd 0, but does not launch a descendant. It therefore does not cover the Windows-specific
STD_INPUT_HANDLEinheritance guarantee introduced by this PR.
Suggested changes
- Check the Win32 return value and keep the ACP transport valid on failure, including rollback where needed.
- Add a Windows descendant-process test proving default child stdin is NUL/EOF while
sys.stdin.bufferstill consumes the protocol stream.
This is an automated hermes-sweeper review.
| import msvcrt | ||
|
|
||
| STD_INPUT_HANDLE = -10 | ||
| ctypes.windll.kernel32.SetStdHandle( |
There was a problem hiding this comment.
SetStdHandle returns a BOOL, but this result is ignored. If it fails, Windows descendants may still inherit the protocol pipe while this function logs that shielding succeeded. Check the return value and preserve or restore a working transport/standard-handle state before continuing.
|
Both problems were real. Fixed in d4d016664. The SetStdHandle result is now checked. The call moved into Added the descendant test: Windows-only, launches a real child with |
d4d0166 to
10cad75
Compare
monerostar
left a comment
There was a problem hiding this comment.
Ubuntu 26.04 / linux-5800x (kernel 7.0.0-28-generic) here.
PR head 10cad75. Live POSIX probe of _shield_stdin_from_children (subprocess with the PR tree on sys.path so the child does not pick up install main):
INHERITED:b''
TRANSPORT:b'protocol-line\n'
rc=0. So after the shield, fd 0 is EOF for anything that inherits it, and the re-homed transport still reads the protocol line. main has no _shield_stdin_from_children.
Note: plain pytest tests/acp/test_entry.py from a worktree against the install venv fails the subprocess probes with AttributeError unless the child sees the PR tree first. That is a local import-path footgun for worktree runs, not a logic fail on the shield itself. The direct probe above is green.
POSIX path looks right from this box. Looks good.
|
Good catch on the worktree footgun — that wasn't local to your box, the probes were resolving |
ee439ea to
eee2895
Compare
eee2895 to
c269d52
Compare
|
Rebased onto Re-verified the premise on current
Both problems from your review remain addressed as of the last round: Tests against the rebased tree:
Ran outside the canonical runner — One note for whoever picks this up: this branch is stacked on #74242 — |
c269d52 to
8373f46
Compare
|
Unstacked from #74242 as offered. Head is Dropped the null-
Verification that nothing was lost in the surgery:
Same caveat as before on the test environment — scratch venv rather than |
Defence in depth for the NousResearch#14036/NousResearch#73693 bug class. Fixing individual call sites (as `scripts/check_subprocess_stdin.py` enforces) only covers code we own and remember to annotate. In ACP mode fd 0 is the protocol pipe, and a child that inherits it can either steal bytes from the transport or block on it and hang the turn. Rather than trust every present and future spawn site, re-home the transport onto a private, non-inheritable duplicate at startup and point fd 0 at the null device, so every descendant inherits NUL. This also covers processes we do not control: per-session MCP servers launched by the client (the IDE's own stdio server among them) and third-party user plugins under `get_hermes_home()/plugins`. Windows needs the extra `SetStdHandle(STD_INPUT_HANDLE, ...)` call: it hands children the process-wide standard handle, not fd 0, so `dup2` alone leaves the pipe reachable. The call is gated on `sys.platform == "win32"`; POSIX needs only the `dup2`, which is ordinary daemon hygiene. Fail-open: if stdin cannot be duplicated (no real stdin under an embedded or test harness) the adapter logs and continues unshielded rather than refusing to start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review raised two problems with the shield, both real. The SetStdHandle result was discarded. On Windows that call IS the shield -- a child spawned with stdin=None inherits the process-wide STD_INPUT_HANDLE, not fd 0 -- so a silent failure there left children inheriting the JSON-RPC pipe while the log still said "ACP stdin shielded from child processes". The one load-bearing call was the one call nobody checked. It also passed the handle untyped. ctypes defaults an untyped integer argument to a C int, and msvcrt.get_osfhandle returns a 64-bit HANDLE on x64, so a large handle value could be truncated before it ever reached Win32. Extract the redirect into `_point_win32_stdin_at`, with argtypes/restype declared, `use_last_error=True` so the failure path can report a real GetLastError, and a True/no-op return off Windows. On failure, roll fd 0 back from the private duplicate and return unshielded rather than continuing half-shielded: fd 0 reading NUL while STD_INPUT_HANDLE still points at the pipe is the worst of both worlds, and a process that reports no shield is easier to diagnose than one that reports a shield it does not have. The seam is also what makes the failure testable at all -- the redirect can now be stubbed to fail on any platform, so the rollback has coverage on Linux CI where SetStdHandle never runs. Second problem: the existing probe read its OWN fd 0 and inferred what a descendant would see. That inference holds on POSIX and is exactly wrong on Windows, which is the platform this fix exists for. Add a Windows-only test that launches a real descendant with stdin=None and asserts it reads EOF while sys.stdin.buffer still carries the protocol stream. It passes against the current implementation -- the guarantee was already being met, it just was not being proven. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shield tests launch their probes as bare subprocesses, so the child resolved acp_adapter against whatever the venv had installed. Run from a worktree against an install venv, that silently probed the wrong code and died with AttributeError on the new symbols -- the footgun monerostar hit in review. Prepend the repo root to the probes' PYTHONPATH so they always exercise the tree the test session imported. Verified both ways on win32: from outside the repo root the rollback probe fails without this and all three pass with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8373f46 to
f307004
Compare
What & why
Defence in depth for the class in #74241. Fixing individual call sites only covers
code we own and remember to annotate — and the guard meant to enforce that has a
blind spot for calls formatted as
subprocess.run(+ newline.In ACP mode fd 0 is the protocol pipe. A child that inherits it can either steal
bytes from the transport or block on it and hang the turn. This re-homes the
transport onto a private, non-inheritable duplicate at startup and points fd 0 at
the null device, so every descendant inherits NUL.
That also covers processes we don't control and can't annotate:
own stdio MCP server is a direct child of the agent);
get_hermes_home()/plugins.Windows needs the extra
SetStdHandle(STD_INPUT_HANDLE, ...): it hands childrenthe process-wide standard handle rather than fd 0, so
dup2alone leaves the pipereachable. Gated on
sys.platform == "win32"; POSIX needs only thedup2, whichis ordinary daemon hygiene.
Fail-open: if stdin can't be duplicated (no real stdin under an embedded or test
harness) it logs and continues unshielded rather than refusing to start.
How to test
tests/acp/test_entry.py::test_shield_stdin_redirects_fd0_to_devnullspawns asubprocess, calls the shield, and asserts (a) fd 0 reads EOF — what a grandchild
would inherit — and (b) the transport still reads the original stream. Without the
shield the same probe reads the protocol line off fd 0.
Platforms
Verified on Windows 11 with an overlapped named pipe as stdin (matching the
JetBrains/Eel launcher). The POSIX path is
dup2-only.Happy to drop this if you'd rather keep to per-call-site fixes — #74241 and #74242 stand alone and fix the reported bug without it.