fix(code-execution): lock down remote kernel/RPC dirs, keep RPC token out of argv

On shared remote backends the execute_code channel created kernel and
sandbox dirs under shared temp at the process umask (775 group-writable
under umask 002), wrote request/result files group-readable, and carried
HERMES_RPC_TOKEN on remote command lines where co-tenant users read argv
via ps for the whole run. A co-tenant could read tool arguments and
results, and on group-writable dirs forge RPC requests dispatched under
the user's approval context.

- All remote dirs are created owner-only (umask 077 + chmod 700, checked
  fail-closed) and every Hermes file write is mode 600.
- The token travels in a sourced env file inside a subshell so the vars
  never enter the backend's session-snapshot dump, and ships via stdin on
  pipe-capable backends so it never enters argv at all.
- The RPC poll loop rejects non-int seq requests before dispatch instead
  of replaying them every cycle.
- tool_result_storage gets the same owner-only treatment for archived
  tool output.

(cherry picked from commit aef21731d7fb8a4e0a6ada4ff9889264df4a8893)
This commit is contained in:
beardthelion
2026-09-24 15:53:50 -05:00
committed by kshitij
parent 5f30cfd5b2
commit 5b8fd7fc32
8 changed files with 538 additions and 46 deletions

View File

@@ -30,6 +30,46 @@ def _default_dispatch(task_id):
return lambda tool_name, tool_args: handle_function_call(tool_name, tool_args, task_id=task_id)
def _private_dirs_cmd(mkdir_dirs, chmod_dirs) -> str:
"""Shell command creating remote dirs owner-only on a shared host. ``umask 077``
makes intermediates and leaves private at creation (no mkdir-then-chmod window
where a co-tenant could open a dir fd); ``chmod`` then repairs a dir that
already existed with permissive modes."""
mkdir = " ".join(shlex.quote(d) for d in mkdir_dirs)
chmod = " ".join(shlex.quote(d) for d in chmod_dirs)
return f"umask 077 && mkdir -p {mkdir} && chmod 700 {chmod}"
def _remote_write_cmd(env, remote_path: str, content: str, *, atomic: bool = False) -> tuple:
"""Build ``(command, stdin_data)`` writing *content* owner-only to *remote_path*.
Payload transport follows the backend's stdin capability: ``pipe`` mode
(ssh, docker, local, singularity — the real shared-host backends) gets the
base64 via real stdin so the content never enters argv, where a co-tenant
can read it via ``/proc/*/cmdline`` for the command's lifetime.
``heredoc``/``payload`` modes (modal, daytona, vercel, managed_modal —
isolated sandboxes where Modal-family stdin delivery is also unreliable)
keep the base64 echo form: the argv window there is one short write rather
than the whole run."""
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
target = shlex.quote(remote_path)
write = (f"base64 -d > {target}.tmp && mv -f {target}.tmp {target}"
if atomic else f"base64 -d > {target}")
if getattr(env, "_stdin_mode", "pipe") == "pipe":
return f"umask 077 && {write}", encoded
return f"umask 077 && echo '{encoded}' | {write}", None
def _remote_write(env, remote_path: str, content: str, *, atomic: bool = False,
timeout: int = 30):
"""Execute an owner-only remote write; returns the execute() result."""
cmd, stdin_data = _remote_write_cmd(env, remote_path, content, atomic=atomic)
kwargs = {"cwd": "/", "timeout": timeout}
if stdin_data is not None:
kwargs["stdin_data"] = stdin_data
return env.execute(cmd, **kwargs)
def _rpc_token_ok(request: dict, rpc_token: str) -> bool:
"""Constant-time token check; an empty server token fails closed. Compared as bytes:
compare_digest raises TypeError on a non-ASCII str, and the token is script-supplied JSON."""
@@ -161,20 +201,23 @@ def _rpc_poll_loop(env, rpc_dir: str, task_id: str, tool_call_log: list, tool_ca
logger.debug("Unauthorized RPC request in %s", req_file)
env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5)
continue
seq = request.get("seq", 0)
if not isinstance(seq, int):
# A non-int seq cannot form the res_NNNNNN name the caller
# polls; formatting it after dispatch would raise, leave the
# request in place, and replay the tool call every cycle.
logger.debug("RPC request with malformed seq in %s", req_file)
env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5)
continue
tool_result = _handle_rpc_request(
request, allowed_tools=allowed_tools, tool_call_counter=tool_call_counter,
max_tool_calls=max_tool_calls, dispatch=dispatch, tool_call_log=tool_call_log,
call_start=call_start, where="remote sandbox",
)
# Write the response atomically (tmp + rename) via echo piping —
# Modal doesn't reliably deliver stdin_data to chained commands.
quoted_res_file = shlex.quote(f"{rpc_dir}/res_{request.get('seq', 0):06d}")
encoded_result = base64.b64encode(tool_result.encode("utf-8")).decode("ascii")
env.execute(
f"echo '{encoded_result}' | base64 -d > {quoted_res_file}.tmp"
f" && mv {quoted_res_file}.tmp {quoted_res_file}",
cwd="/", timeout=60,
)
# Atomic (tmp + rename) and owner-only; results carry tool output
# on a shared-host backend.
_remote_write(env, f"{rpc_dir}/res_{seq:06d}", tool_result,
atomic=True, timeout=60)
env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5)
except Exception as e:
if not stop_event.is_set():

View File

@@ -11,7 +11,6 @@ per-call script ship, tool calls as request files polled via env.execute()
scrubbing, interpreter/cwd), tools/code_execution_rpc.py (RPC servers).
"""
import base64
import json
import logging
import os
@@ -30,7 +29,7 @@ from tools.registry import registry, tool_error
from hermes_time import get_timezone_name
from tools.code_execution_env import _resolve_child_cwd, _resolve_child_python
from tools.code_execution_rpc import _rpc_poll_loop
from tools.code_execution_rpc import _private_dirs_cmd, _remote_write, _rpc_poll_loop
from tools.tool_output_truncate import head_tail_split, truncation_notice
logger = logging.getLogger(__name__)
@@ -358,7 +357,10 @@ def _call(tool_name, args):
# (or any non-UTF-8 locale) the default open() mode would mangle
# non-ASCII chars in tool args when encoding them as JSON.
tmp = req_file + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
# The request carries the RPC token and tool args: owner-only even under a
# permissive process umask.
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({
"tool": tool_name,
"args": args,
@@ -451,10 +453,35 @@ def _get_or_create_env(task_id: str):
def _ship_file_to_remote(env, remote_path: str, content: str) -> None:
"""Write *content* to *remote_path* via ``echo … | base64 -d`` — some backends (Modal) don't
reliably deliver stdin_data to chained commands; base64 is shell-safe inside single quotes."""
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
env.execute(f"echo '{encoded}' | base64 -d > {shlex.quote(remote_path)}", cwd="/", timeout=30)
"""Write *content* owner-only to *remote_path*. stdin-capable backends carry
the payload on stdin so it never appears in remote argv; the rest get the
base64 echo form (see _remote_write_cmd). Raises on write failure: the
caller ships secrets and code into dirs it believes are locked down, so a
silent failure would run the next step against a missing or half-written
file."""
result = _remote_write(env, remote_path, content)
if not isinstance(result, dict) or result.get("returncode", 1) != 0:
raise RuntimeError(
f"remote file ship failed for {remote_path!r}: "
f"{(result or {}).get('output', result)!r}")
def _ship_env_file_and_launch_prefix(env, remote_dir: str, env_name: str,
env_map: dict) -> str:
"""Ship *env_map* as KEY=value lines to ``remote_dir/env_name`` and return a
command prefix that sources it inside a subshell; the caller appends the
launch command and the closing ``)``.
The subshell is load-bearing: every env.execute() runs inside the backend's
session wrapper, which re-dumps ``export -p`` into the shared session
snapshot after each command. A plain ``set -a; . file`` in the outer shell
would export HERMES_RPC_TOKEN/PYTHONPATH/TZ into that snapshot and re-export
them into every later command on the backend (the #71296 snapshot-leak
class). The token also stays off the remote shell's argv, which co-tenant
users can read via ps for the command's lifetime."""
lines = "".join(f"{k}={shlex.quote(v)}\n" for k, v in env_map.items())
_ship_file_to_remote(env, f"{remote_dir}/{env_name}", lines)
return f"cd {shlex.quote(remote_dir)} && ( set -a && . ./{env_name} && set +a && "
def _env_temp_dir(env: Any) -> str:
@@ -565,10 +592,17 @@ def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
serve file-RPC from a polling thread, run, clean up."""
sandbox_dir = f"{_env_temp_dir(env)}/hermes_exec_{uuid.uuid4().hex[:12]}"
quoted_sandbox_dir = shlex.quote(sandbox_dir)
quoted_rpc_dir = shlex.quote(f"{sandbox_dir}/rpc")
tool_call_counter, stop_event, rpc_thread = [0], threading.Event(), None
try:
env.execute(f"mkdir -p {quoted_rpc_dir}", cwd="/", timeout=10)
# Private dirs: the sandbox lives under a shared temp dir and carries the
# RPC token (in req files) and tool results. Fail closed on setup
# failure rather than ship secrets into a dir that stayed permissive.
setup = env.execute(
_private_dirs_cmd([f"{sandbox_dir}/rpc"], [sandbox_dir, f"{sandbox_dir}/rpc"]),
cwd="/", timeout=10)
if not isinstance(setup, dict) or setup.get("returncode", 1) != 0:
raise RuntimeError(
f"remote sandbox setup failed: {(setup or {}).get('output', setup)!r}")
rpc_token = secrets.token_urlsafe(32)
_ship_file_to_remote(env, f"{sandbox_dir}/hermes_tools.py",
generate_hermes_tools_module(list(sandbox_tools), transport="file"))
@@ -581,13 +615,19 @@ def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
args=(env, f"{sandbox_dir}/rpc", effective_task_id, [], tool_call_counter,
max_tool_calls, sandbox_tools, stop_event, rpc_token))
rpc_thread.start()
env_prefix = (f"HERMES_RPC_DIR={quoted_rpc_dir} HERMES_RPC_TOKEN={shlex.quote(rpc_token)} "
"PYTHONDONTWRITEBYTECODE=1")
# The token travels in a sourced env file, never in argv. No umask on
# the launch command: the 700 dirs + explicit 0600 writes cover Hermes'
# files, and user code keeps the remote's default file modes.
env_map = {"HERMES_RPC_DIR": f"{sandbox_dir}/rpc",
"HERMES_RPC_TOKEN": rpc_token,
"PYTHONDONTWRITEBYTECODE": "1"}
tz = get_timezone_name() # routed profile's timezone, not the bridged default's
if tz:
env_prefix += f" TZ={shlex.quote(tz)}"
env_map["TZ"] = tz
launch_prefix = _ship_env_file_and_launch_prefix(
env, sandbox_dir, "sandbox.env", env_map)
logger.info("Executing code on %s backend (task %s)...", env_type, effective_task_id[:8])
script_result = env.execute(f"cd {quoted_sandbox_dir} && {env_prefix} python3 script.py",
script_result = env.execute(f"{launch_prefix} exec python3 script.py )",
timeout=timeout)
stdout_text = script_result.get("output", "") or ""
exit_code = script_result.get("returncode", -1)

View File

@@ -83,7 +83,10 @@ def main():
payload, _ = run_cell(request, execution_count)
res_name = name.replace("cell_req_", "cell_res_")
tmp = os.path.join(CELLS, res_name + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
# Cell results carry the executed code's output: owner-only, even if
# the process umask is permissive.
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
os.replace(tmp, os.path.join(CELLS, res_name))
if payload["status"] == "exit":
@@ -204,23 +207,51 @@ atexit.register(shutdown_all_remote_kernels)
def _spawn_remote_kernel(env, env_type: str, owner: str, task_env_id: str,
sandbox_tools: frozenset, *, idle_exit: int) -> Optional[RemoteKernel]:
"""Start a detached kernel runner on the remote. None on failure (dir removed)."""
from hermes_time import get_timezone_name
from tools.code_execution_rpc import _private_dirs_cmd
from tools.code_execution_tool import (
MAX_STDOUT_BYTES, _ship_file_to_remote, _env_temp_dir, generate_hermes_tools_module,
MAX_STDOUT_BYTES, _ship_file_to_remote, _env_temp_dir,
_ship_env_file_and_launch_prefix, generate_hermes_tools_module,
)
kernel_dir = f"{_env_temp_dir(env)}/hermes_rkernel_{uuid.uuid4().hex[:12]}"
q_dir = shlex.quote(kernel_dir)
kernel = None
try:
_sh(env, f"mkdir -p {q_dir}/cells {q_dir}/rpc")
# Private dirs: the kernel dir lives under a shared temp dir and carries
# the RPC token (in req files), tool results, and cell code/output.
# Fail closed on setup failure rather than ship secrets into a dir that
# stayed permissive.
setup = env.execute(
_private_dirs_cmd([f"{kernel_dir}/cells", f"{kernel_dir}/rpc"],
[kernel_dir, f"{kernel_dir}/cells", f"{kernel_dir}/rpc"]),
cwd="/", timeout=15)
if not isinstance(setup, dict) or setup.get("returncode", 1) != 0:
raise RuntimeError(
f"remote kernel dir setup failed: {(setup or {}).get('output', setup)!r}")
rpc_token = secrets.token_urlsafe(32)
_ship_file_to_remote(env, f"{kernel_dir}/kernel_runner.py", REMOTE_KERNEL_RUNNER_SOURCE.format(
cell_source=RUNNER_CELL_SOURCE, capture_limit=MAX_STDOUT_BYTES, idle_exit=idle_exit))
_ship_file_to_remote(env, f"{kernel_dir}/hermes_tools.py",
generate_hermes_tools_module(list(sandbox_tools), transport="file"))
env_prefix = (f"HERMES_KERNEL_DIR={q_dir} HERMES_RPC_DIR={shlex.quote(kernel_dir + '/rpc')} "
f"HERMES_RPC_TOKEN={shlex.quote(rpc_token)} PYTHONDONTWRITEBYTECODE=1 PYTHONPATH={q_dir}")
started = _sh(env, f"cd {q_dir} && nohup env {env_prefix} python3 kernel_runner.py "
f"> {q_dir}/runner.log 2>&1 & echo PID:$!", timeout=20)
env_map = {"HERMES_KERNEL_DIR": kernel_dir,
"HERMES_RPC_DIR": f"{kernel_dir}/rpc",
"HERMES_RPC_TOKEN": rpc_token,
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONPATH": kernel_dir}
tz = get_timezone_name() # routed profile's timezone, matching the per-call path
if tz:
env_map["TZ"] = tz
launch_prefix = _ship_env_file_and_launch_prefix(
env, kernel_dir, "kernel.env", env_map)
# kernel.env is removed after sourcing: the runner's env keeps the
# values, so the token file need not sit at rest for the kernel's
# lifetime. runner.log is pre-created 600 so the launch redirect never
# lands at the remote's default umask. The inner `&` stays inside the
# subshell where `$!` resolves to the runner pid.
started = _sh(env, f"{launch_prefix} rm -f ./kernel.env && "
f"touch runner.log && chmod 600 runner.log && "
f"{{ nohup python3 kernel_runner.py > runner.log 2>&1 & "
f'echo "PID:$!"; }} )', timeout=20)
pid = next((line.strip()[4:].strip() for line in started.splitlines()
if line.strip().startswith("PID:")), "")
if not pid.isdigit():

View File

@@ -28,7 +28,8 @@ from typing import Iterable
# name/prefix instead of grepping declare lines (see below / issue #71296).
_SNAPSHOT_EXCLUDED_ENV_REGEX = (
"^declare -x (HERMES_SESSION_|HERMES_UI_SESSION_ID|HERMES_CRON_AUTO_DELIVER_|"
"HERMES_CRON_SESSION|HERMES_BROWSER_CONTROL_|HERMES_DELEGATED_CHILD_CONTEXT)")
"HERMES_CRON_SESSION|HERMES_BROWSER_CONTROL_|HERMES_DELEGATED_CHILD_CONTEXT|"
"HERMES_RPC_|HERMES_KERNEL_DIR)")
_SHELL_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# mktemp template suffix + the shell variable holding the allocated temp path.
@@ -76,6 +77,10 @@ def _export_dump_excluding_session_vars(tmp_path: str, excluded_names: Iterable[
# env; a snapshot taken inside that window would re-assert them on every
# later ``source`` and fence the PARENT session's kanban CLI (#90782).
"HERMES_DELEGATED_CHILD_CONTEXT HERMES_CRON_SESSION "
# Remote code-execution channel vars (RPC token, kernel/rpc dirs): a
# leaked token in the snapshot would re-export into every later command
# on the backend and outlive the private dir it protects.
"${!HERMES_RPC_*} HERMES_KERNEL_DIR "
f"HERMES_UI_SESSION_ID{extra_unset} 2>/dev/null; "
"export -p; ) || true; } "
f"> {tmp_path}")

View File

@@ -183,7 +183,11 @@ def _write_to_sandbox(content: str, remote_path: str, env) -> bool:
truncation on payload backends). A measured mismatch removes the archive and fails closed;
an unprobeable backend (no ``wc``, exec error, unparseable output) stays best-effort success."""
storage_dir = os.path.dirname(remote_path)
cmd = f"mkdir -p {shlex.quote(storage_dir)} && cat > {shlex.quote(remote_path)}"
# Private dir: archived results carry tool output (can hold secrets) under a
# shared temp root on remote backends. The umask also covers the cat redirect.
from tools.code_execution_rpc import _private_dirs_cmd
cmd = (f"{_private_dirs_cmd([storage_dir], [storage_dir])} "
f"&& cat > {shlex.quote(remote_path)}")
if env.execute(cmd, timeout=30, stdin_data=content).get("returncode", 1) != 0:
return False