Skip to content

fix(acp): shield the JSON-RPC stdin from every child process - #74243

Open
lxman wants to merge 3 commits into
NousResearch:mainfrom
lxman:fix/acp-shield-stdin-from-children
Open

fix(acp): shield the JSON-RPC stdin from every child process#74243
lxman wants to merge 3 commits into
NousResearch:mainfrom
lxman:fix/acp-shield-stdin-from-children

Conversation

@lxman

@lxman lxman commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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:

  • per-session MCP servers launched on the client's behalf (in JetBrains, the IDE's
    own stdio MCP server is a direct child of the agent);
  • third-party user plugins under get_hermes_home()/plugins.

Windows needs the extra SetStdHandle(STD_INPUT_HANDLE, ...): it hands children
the process-wide standard handle rather than fd 0, so dup2 alone leaves the pipe
reachable. Gated on sys.platform == "win32"; POSIX needs only the dup2, which
is 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_devnull spawns a
subprocess, 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.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/acp Agent Communication Protocol adapter platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Jul 29, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:250 in commit d565b67529c947d65ee3615ad003ec5eac3bf752 ignores SetStdHandle'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_HANDLE inheritance 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.buffer still consumes the protocol stream.

This is an automated hermes-sweeper review.

Comment thread acp_adapter/entry.py Outdated
import msvcrt

STD_INPUT_HANDLE = -10
ctypes.windll.kernel32.SetStdHandle(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
@lxman

lxman commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Both problems were real. Fixed in d4d016664.

The SetStdHandle result is now checked. The call moved into _point_win32_stdin_at, with argtypes/restype declared (untyped, ctypes passes the HANDLE as a C int, which truncates on x64) and use_last_error=True so the warning carries a real GetLastError code. On failure it rolls fd 0 back from the private duplicate and returns unshielded rather than continuing half-shielded — fd 0 at 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 doesn't have. The seam also makes the failure path testable on Linux CI, where SetStdHandle never runs.

Added the descendant test: Windows-only, launches a real child with stdin=None, asserts it reads EOF while sys.stdin.buffer still carries the protocol stream. Worth noting it passes against the previous implementation too — the STD_INPUT_HANDLE guarantee was being met, it just wasn't proven. The rollback test is the one that fails without the new code.

@lxman
lxman force-pushed the fix/acp-shield-stdin-from-children branch from d4d0166 to 10cad75 Compare August 3, 2026 14:07

@monerostar monerostar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lxman

lxman commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on the worktree footgun — that wasn't local to your box, the probes were resolving acp_adapter against the venv instead of the tree under test. Fixed in ee439ea: the probe children now get the repo root prepended to PYTHONPATH. Reproduced your failure from outside the repo root and confirmed all three pass with the pin, on Windows this time — so the descendant test has now run green on both platforms.

@lxman
lxman force-pushed the fix/acp-shield-stdin-from-children branch from ee439ea to eee2895 Compare August 11, 2026 02:45
@lxman lxman mentioned this pull request Aug 11, 2026
13 tasks
@lxman

lxman commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 2cdb30a (current main). Works at a different layer from #69083 — shields the ACP entry point's Win32 stdin handle so every child process is protected, not just the bash probe. Complementary defense-in-depth.

@lxman
lxman force-pushed the fix/acp-shield-stdin-from-children branch from eee2895 to c269d52 Compare August 14, 2026 22:21
@lxman

lxman commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 31e571acf (current main) — 699 commits of drift since the last rebase base, no conflicts. Head is c269d52bd.

Re-verified the premise on current main:

  • acp_adapter/entry.py on main still has no _shield_stdin_from_children / SetStdHandle — the shield is not present by another route.
  • tools/environments/local.py still calls subprocess.run without stdin= (:839, :911), so descendants still inherit the ACP protocol pipe.

Both problems from your review remain addressed as of the last round: SetStdHandle's BOOL is checked inside _point_win32_stdin_at with argtypes/restype declared and use_last_error=True, rolling fd 0 back and returning unshielded on failure; and the descendant test launches a real child asserting EOF on inherited stdin while sys.stdin.buffer still carries the protocol stream.

Tests against the rebased tree:

  • tests/acp/test_entry.py — 6 passed, 1 skipped (test_shield_stdin_denies_the_pipe_to_a_real_descendant, skipped with "STD_INPUT_HANDLE inheritance is Windows-specific" — this box is Linux; it ran green on Windows in the previous round)
  • tests/acp/test_server.py — 31 passed

Ran outside the canonical runner — scripts/run_tests.sh couldn't find a venv with pytest on this box, so I used a scratch venv with the repo tree and the install's site-packages on PYTHONPATH, and set TZ=UTC LANG=C.UTF-8 PYTHONHASHSEED=0 to match what the runner enforces. Worth a CI confirmation rather than taking my local run as equivalent — particularly for the Windows path, which nothing here exercises.

One note for whoever picks this up: this branch is stacked on #74242de20e6c7e (the null-final_response fix) is its first commit, so acp_adapter/server.py and tests/acp/test_server.py appear in this diff too. If #74242 lands first this rebases down to the three entry.py commits; if it's easier to take them in either order, I can unstack.

@lxman
lxman force-pushed the fix/acp-shield-stdin-from-children branch from c269d52 to 8373f46 Compare August 14, 2026 22:36
@lxman

lxman commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Unstacked from #74242 as offered. Head is 8373f46e3, still on 31e571acf.

Dropped the null-final_response commit that was sitting at the base of this branch, so the diff is now just the three entry.py commits:

8373f46e3 test(acp): pin the probe children's import path to the tree under test
1a72670ed fix(acp): verify the Win32 stdin redirect instead of assuming it
141e5ad79 fix(acp): shield the JSON-RPC stdin from every child process

acp_adapter/entry.py + tests/acp/test_entry.py only — acp_adapter/server.py and tests/acp/test_server.py are no longer in this PR. The two PRs now have no file overlap and can land in either order, independently.

Verification that nothing was lost in the surgery:

  • the entry.py + test_entry.py diff against main is byte-identical to the stacked version (same md5sum), so this is purely a base change
  • tests/acp/test_entry.py — 6 passed, 1 skipped (the Windows-only descendant test)
  • tests/acp/test_server.py — 30 passed, down from 31, which is the expected drop: the missing one is the regression test that belongs to fix(acp): tolerate a null final_response when a turn is cancelled #74242 and correctly left with it

Same caveat as before on the test environment — scratch venv rather than scripts/run_tests.sh, so CI is the authority, and nothing here exercises the Windows path.

lxman and others added 3 commits August 14, 2026 22:03
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>
@lxman
lxman force-pushed the fix/acp-shield-stdin-from-children branch from 8373f46 to f307004 Compare August 15, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/acp Agent Communication Protocol adapter P3 Low — cosmetic, nice to have platform/windows Native Windows-specific behavior or breakage sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants