From 5178b3f056461e637e55b3894b707aad2487aa81 Mon Sep 17 00:00:00 2001 From: SahilRakhaiya05 Date: Wed, 1 Jul 2026 02:52:04 -0700 Subject: [PATCH] fix(code-exec): bind execute_code tool socket to a per-session RPC token The execute_code sandbox exposed its tool-call RPC (AF_UNIX socket and remote file-poll transports) without any caller check, so any local process that could reach the socket / rpc dir could dispatch terminal-capable tool calls through the parent. Mint a per-session HERMES_RPC_TOKEN, pass it to the sandboxed child, and require a timing-safe match on every request in both _rpc_server_loop and _rpc_poll_loop. Empty/missing/wrong token fails closed. Salvaged from #44073 (per-session RPC token). Added timing-safe secrets.compare_digest comparison and fail-closed regression tests. Co-authored-by: Hermes Agent --- tests/tools/test_code_execution.py | 127 +++++++++++++++++++++++++++++ tools/code_execution_tool.py | 39 ++++++++- 2 files changed, 162 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 07dc188600c..03a2a1a6e71 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -17,6 +17,7 @@ import pytest import json import os +import socket import time os.environ["TERMINAL_ENV"] = "local" @@ -1006,5 +1007,131 @@ for i in range(15000): self.assertIn("total", output) +class TestRpcTokenAuthorization(unittest.TestCase): + """The per-session RPC token must gate socket dispatch (fail-closed). + + Regression coverage for the execute_code tool-socket hardening: a + request without the matching HERMES_RPC_TOKEN must be rejected before + the tool is dispatched, while a request carrying the correct token + round-trips normally. + """ + + def _drive_server(self, rpc_token, requests): + """Run _rpc_server_loop against a real AF_UNIX socketpair. + + Sends each dict in *requests* as a newline-delimited JSON message + and returns the list of decoded JSON responses. + """ + from tools.code_execution_tool import _rpc_server_loop + + # socketpair gives us a connected client end and a "server" end we + # can hand to accept() by wrapping it in a tiny listener shim. + srv, cli = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + + class _OneShotListener: + """Minimal object exposing the .accept()/.settimeout() the loop uses.""" + + def __init__(self, conn): + self._conn = conn + self._served = False + + def settimeout(self, _t): + pass + + def accept(self): + if self._served: + raise socket.timeout() + self._served = True + return self._conn, ("peer", 0) + + listener = _OneShotListener(srv) + stop_event = threading.Event() + tool_call_log = [] + tool_call_counter = [0] + + def _run(): + with patch( + "model_tools.handle_function_call", + side_effect=_mock_handle_function_call, + ): + _rpc_server_loop( + listener, + "test-task", + tool_call_log, + tool_call_counter, + max_tool_calls=10, + allowed_tools=frozenset({"terminal"}), + stop_event=stop_event, + rpc_token=rpc_token, + ) + + t = threading.Thread(target=_run, daemon=True) + t.start() + + responses = [] + try: + for req in requests: + cli.sendall((json.dumps(req) + "\n").encode()) + cli.settimeout(5) + buf = b"" + while len(responses) < len(requests): + chunk = cli.recv(65536) + if not chunk: + break + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + line = line.strip() + if line: + responses.append(json.loads(line.decode())) + finally: + stop_event.set() + cli.close() + srv.close() + t.join(timeout=5) + return responses + + def test_missing_token_rejected(self): + """A request with no token is rejected as Unauthorized.""" + resp = self._drive_server( + "secret-token", [{"tool": "terminal", "args": {"command": "echo hi"}}] + ) + self.assertEqual(len(resp), 1) + self.assertIn("Unauthorized", resp[0].get("error", "")) + + def test_wrong_token_rejected(self): + """A request with a mismatched token is rejected as Unauthorized.""" + resp = self._drive_server( + "secret-token", + [{"tool": "terminal", "args": {"command": "echo hi"}, "token": "nope"}], + ) + self.assertEqual(len(resp), 1) + self.assertIn("Unauthorized", resp[0].get("error", "")) + + def test_matching_token_dispatched(self): + """A request carrying the correct token round-trips to the tool.""" + resp = self._drive_server( + "secret-token", + [{"tool": "terminal", "args": {"command": "echo hi"}, "token": "secret-token"}], + ) + self.assertEqual(len(resp), 1) + self.assertNotIn("Unauthorized", json.dumps(resp[0])) + self.assertIn("mock output for: echo hi", json.dumps(resp[0])) + + def test_empty_server_token_fails_closed(self): + """An empty server-side token rejects everything (fail-closed).""" + resp = self._drive_server( + "", [{"tool": "terminal", "args": {"command": "echo hi"}, "token": ""}] + ) + self.assertEqual(len(resp), 1) + self.assertIn("Unauthorized", resp[0].get("error", "")) + + def test_generated_module_sends_token(self): + """The generated hermes_tools module reads HERMES_RPC_TOKEN and sends it.""" + src = generate_hermes_tools_module(["terminal"], transport="uds") + self.assertIn("HERMES_RPC_TOKEN", src) + self.assertIn('"token"', src) + + if __name__ == "__main__": unittest.main() diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index e3b247bfa06..349d4810dfb 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -34,6 +34,7 @@ import json import logging import os import platform +import secrets import shlex import socket import subprocess @@ -381,7 +382,11 @@ def _connect(): def _call(tool_name, args): """Send a tool call to the parent process and return the parsed result.""" - request = json.dumps({"tool": tool_name, "args": args}) + "\\n" + request = json.dumps({ + "tool": tool_name, + "args": args, + "token": os.environ.get("HERMES_RPC_TOKEN", ""), + }) + "\\n" with _call_lock: conn = _connect() conn.sendall(request.encode()) @@ -434,7 +439,12 @@ def _call(tool_name, args): # non-ASCII chars in tool args when encoding them as JSON. tmp = req_file + ".tmp" with open(tmp, "w", encoding="utf-8") as f: - json.dump({"tool": tool_name, "args": args, "seq": seq}, f) + json.dump({ + "tool": tool_name, + "args": args, + "seq": seq, + "token": os.environ.get("HERMES_RPC_TOKEN", ""), + }, f) os.rename(tmp, req_file) # Wait for response with adaptive polling @@ -482,6 +492,7 @@ def _rpc_server_loop( max_tool_calls: int, allowed_tools: frozenset, stop_event: threading.Event, + rpc_token: str, ): """ Accept one client connection and dispatch tool-call requests until @@ -527,6 +538,13 @@ def _rpc_server_loop( conn.sendall((resp + "\n").encode()) continue + if not rpc_token or not secrets.compare_digest( + str(request.get("token") or ""), rpc_token + ): + resp = json.dumps({"error": "Unauthorized RPC request"}) + conn.sendall((resp + "\n").encode()) + continue + tool_name = request.get("tool", "") tool_args = request.get("args", {}) @@ -750,6 +768,7 @@ def _rpc_poll_loop( max_tool_calls: int, allowed_tools: frozenset, stop_event: threading.Event, + rpc_token: str, ): """Poll the remote filesystem for tool call requests and dispatch them. @@ -803,6 +822,13 @@ def _rpc_poll_loop( env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5) continue + if not rpc_token or not secrets.compare_digest( + str(request.get("token") or ""), rpc_token + ): + logger.debug("Unauthorized RPC request in %s", req_file) + env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5) + continue + tool_name = request.get("tool", "") tool_args = request.get("args", {}) seq = request.get("seq", 0) @@ -942,6 +968,8 @@ def _execute_remote( f"mkdir -p {quoted_rpc_dir}", cwd="/", timeout=10, ) + rpc_token = secrets.token_urlsafe(32) + # Generate and ship files tools_src = generate_hermes_tools_module( list(sandbox_tools), transport="file", @@ -957,7 +985,7 @@ def _execute_remote( args=( env, f"{sandbox_dir}/rpc", effective_task_id, tool_call_log, tool_call_counter, max_tool_calls, - sandbox_tools, stop_event, + sandbox_tools, stop_event, rpc_token, ), daemon=True, ) @@ -966,6 +994,7 @@ def _execute_remote( # Build environment variable prefix for the script env_prefix = ( f"HERMES_RPC_DIR={shlex.quote(f'{sandbox_dir}/rpc')} " + f"HERMES_RPC_TOKEN={shlex.quote(rpc_token)} " f"PYTHONDONTWRITEBYTECODE=1" ) tz = os.getenv("HERMES_TIMEZONE", "").strip() @@ -1204,6 +1233,7 @@ def execute_code( f.write(code) # --- Start RPC server --- + rpc_token = secrets.token_urlsafe(32) # Two transports: # POSIX: AF_UNIX stream socket on sock_path, chmod 0600 for # owner-only access. Filesystem permissions gate the socket. @@ -1230,7 +1260,7 @@ def execute_code( target=propagate_context_to_thread(_rpc_server_loop), args=( server_sock, task_id, tool_call_log, - tool_call_counter, max_tool_calls, sandbox_tools, stop_event, + tool_call_counter, max_tool_calls, sandbox_tools, stop_event, rpc_token, ), daemon=True, ) @@ -1248,6 +1278,7 @@ def execute_code( # or spawn a subprocess. See ``_scrub_child_env`` for the rules. child_env = _scrub_child_env(os.environ) child_env["HERMES_RPC_SOCKET"] = rpc_endpoint + child_env["HERMES_RPC_TOKEN"] = rpc_token child_env["PYTHONDONTWRITEBYTECODE"] = "1" # Force UTF-8 for the child's stdio and default file encoding. #