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

@@ -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():