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:
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user