Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions agents/hermes/runtime-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -1326,10 +1326,12 @@ def refresh_hashes(
mode: str,
mcp_transition: str = "preserve",
) -> None:
"""Advance the durable MCP intended/applied state without blessing drift.
"""Advance the durable MCP intended/applied state around one config snapshot.

``preserve`` requires current config to equal intended. ``intend`` records
current config as the next intent while retaining the last applied digest.
``preserve`` requires current MCP config to equal intended. ``adopt`` records
Hermes-owned MCP config as the next intent and supersedes stale host intent.
``intend`` records current config as the next managed intent while retaining
the last applied digest.
``rollback`` requires restored config to equal the prior applied digest,
then conservatively records restored/failed-candidate until reload health is
proven. ``apply`` is a metadata-only intended/intended commit and requires
Expand All @@ -1341,7 +1343,7 @@ def refresh_hashes(
config_path = os.path.join(hermes_dir, "config.yaml")
env_path = os.path.join(hermes_dir, ".env")
compat_hash = os.path.join(hermes_dir, ".config-hash")
if mcp_transition not in {"preserve", "intend", "rollback", "apply"}:
if mcp_transition not in {"preserve", "adopt", "intend", "rollback", "apply"}:
raise UnsafePathError("refusing unsupported Hermes MCP hash transition")

# Snapshot-stability/TOCTOU contract: derive the config hash and canonical
Expand Down Expand Up @@ -1387,6 +1389,9 @@ def refresh_hashes(
raise UnsafePathError(
"Hermes MCP config differs from persisted intended state"
)
elif mcp_transition == "adopt":
if not secrets.compare_digest(current_mcp, state.intended):
state = McpHashState(current_mcp, state.applied)
elif mcp_transition == "intend":
if state.intended != state.applied and not secrets.compare_digest(
current_mcp, state.intended
Expand Down Expand Up @@ -1433,6 +1438,28 @@ def assert_inputs_stable() -> None:
config.close()
env.close()

# Restart validates once before sealing and once after. Keep a current
# anchor on the same inode so the second pass cannot invalidate seal state.
if mcp_transition == "adopt" and secrets.compare_digest(
hash_text, source_hash_text
):
compatibility_matches = True
if mode == "both":
try:
compatibility_matches = secrets.compare_digest(
_read_hash_file(compat_hash), source_hash_text
)
except FileNotFoundError:
compatibility_matches = False
if compatibility_matches:
integrity = inspect_mcp_integrity_snapshot(
hermes_dir,
state_path,
compat_hash if mode == "both" else None,
)
assert_mcp_integrity_snapshot_current(integrity)
return

# `both` is the transaction contract: both trust anchors must advance or
# the caller rolls the config write back. `compat` remains best-effort for
# legacy startup paths where an old image can expose a read-only in-tree
Expand Down Expand Up @@ -3361,6 +3388,9 @@ def main() -> int:
parser.add_argument(
"--mode", choices=("strict", "compat", "both"), default="strict"
)
parser.add_argument(
"--mcp-transition", choices=("preserve", "adopt"), default="preserve"
)
parser.add_argument("--state-file", default="")
parser.add_argument("--expected-config-sha256", default="")
parser.add_argument("--lock-token", default="")
Expand All @@ -3375,6 +3405,8 @@ def main() -> int:
raise UnsafePathError(
"--mcp-state-exit-code requires inspect-mcp-integrity"
)
if args.mcp_transition != "preserve" and args.action != "refresh-hashes":
raise UnsafePathError("--mcp-transition requires refresh-hashes")
_validate_action_readiness(args.action, args.startup_owner)
if args.action == "ensure-api-key":
if not args.hash_file:
Expand All @@ -3383,7 +3415,12 @@ def main() -> int:
elif args.action == "refresh-hashes":
if not args.hash_file:
raise UnsafePathError("refresh-hashes requires --hash-file")
refresh_hashes(args.hermes_dir, args.hash_file, args.mode)
refresh_hashes(
args.hermes_dir,
args.hash_file,
args.mode,
mcp_transition=args.mcp_transition,
)
elif args.action == "inspect-mcp-integrity":
if not args.hash_file:
raise UnsafePathError("inspect-mcp-integrity requires --hash-file")
Expand Down
67 changes: 35 additions & 32 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
# - Gateway listens on internal port 18642, socat forwards the API to 8642
# - Dashboard listens on a private loopback port, socat forwards it to 18789
#
# SECURITY: The gateway runs as a separate user so the sandboxed agent cannot
# kill it or restart it with a tampered config. Config hash is verified at
# startup to detect tampering.
# SECURITY: The direct-root gateway runs as a separate user so the sandboxed
# agent cannot control its process lifecycle. Hermes config remains mutable;
# restart transactions validate its paths, secret boundary, and managed MCP
# state before a replacement consumes it.

set -euo pipefail

Expand Down Expand Up @@ -336,7 +337,6 @@ HERMES_RESTART_SEALED=0
HERMES_RESTART_UNSEALING=0
HERMES_RESTART_SIGNAL_PENDING=0
HERMES_MCP_RECONCILE_PENDING=0
HERMES_MCP_INTEGRITY_FAILED=0

# A same-container PID 1 restart can retain /run. Revoke the prior readiness
# lease before any startup migration or mutable config read; host mutations are
Expand Down Expand Up @@ -554,12 +554,13 @@ hermes_fatal_unproven_child() {
# In managed OpenShell, exiting this non-root supervisor would leave PID 1
# and the unproven child alive. Bash's job table can still wait for the exact
# `$!` child without treating a reused numeric PID as authority to signal it.
# Quarantine the supervisor after that child exits; only sandbox destruction
# may tear down a process tree whose identities could not be established.
# Quarantine this supervisor after that child exits. A sandbox stop/start
# replaces the supervisor through the sandbox lifecycle without asking this
# process to signal a child whose identity it could not establish.
echo "[CRITICAL] Newly launched Hermes ${role} pid ${pid} failed exact role identity capture; quarantining the managed startup supervisor without signaling the unproven child" >&2
trap ':' TERM INT
wait "$pid" 2>/dev/null || true
echo "[CRITICAL] Unproven Hermes ${role} child exited; managed supervisor remains quarantined until sandbox recreation" >&2
echo "[CRITICAL] Unproven Hermes ${role} child exited; relaunch is stopped for this supervisor instance; correct the reported failure, then stop and start the sandbox" >&2
while :; do
sleep 60 || true
done
Expand Down Expand Up @@ -2018,11 +2019,13 @@ refresh_hermes_provider_placeholders() {

refresh_hermes_runtime_config_hashes() {
local mode="${1:-strict}"
local mcp_transition="${2:-preserve}"
local cmd=(
"$_HERMES_PYTHON" -I "$_HERMES_RUNTIME_CONFIG_GUARD" refresh-hashes
--hermes-dir "$HERMES_DIR"
--hash-file "$HERMES_HASH_FILE"
--mode "$mode"
--mcp-transition "$mcp_transition"
--startup-owner
)
if [ "$mode" = "compat" ] && [ "$(id -u)" -eq 0 ]; then
Expand Down Expand Up @@ -2070,12 +2073,10 @@ inspect_hermes_mcp_integrity() {
0) HERMES_MCP_RECONCILE_PENDING=0 ;;
10) HERMES_MCP_RECONCILE_PENDING=1 ;;
*)
HERMES_MCP_INTEGRITY_FAILED=1
echo "[SECURITY] HERMES_MCP_CONFIG_DRIFT: MCP intent cannot be matched to the persisted gateway state; rebuild the sandbox from its NemoClaw registry state" >&2
return 1
;;
esac
HERMES_MCP_INTEGRITY_FAILED=0
}

commit_hermes_mcp_applied_if_pending() {
Expand Down Expand Up @@ -2204,13 +2205,14 @@ prepare_hermes_gateway_restart() {
return 1
fi

# A restart is a lifecycle action, not authority to bless arbitrary bytes
# written by the sandbox user. Supported host config commands refresh the
# root-owned strict hash when they make a change; direct in-sandbox edits do
# not. Require that trusted anchor instead of chowning attacker-controlled
# paths or adopting a new hash here.
# Hermes owns its mutable config. Adopt one stable snapshot before sealing
# restart inputs. A direct MCP change becomes pending and is committed only
# after replacement health. Host reconciliation reports any registry mismatch
# without making that host state a precondition for Hermes to run.
HERMES_RESTART_FAILURE_CODE=hash-mismatch
verify_hermes_config_integrity || return 1
refresh_hermes_runtime_config_hashes both adopt || return 1
HERMES_RESTART_FAILURE_CODE=mcp-integrity
inspect_hermes_mcp_integrity "$HERMES_HASH_FILE" || return 1
prepare_hermes_lazy_dependencies
}

Expand Down Expand Up @@ -2638,14 +2640,12 @@ handle_hermes_gateway_control_request() {
local failure_code

if [ "$GATEWAY_CONTROL_ACTION" = "probe" ]; then
if ! prepare_hermes_gateway_restart; then
# Probe verifies the running process and credential boundary. It does not
# adopt mutable config or change MCP transaction state.
if ! validate_running_hermes_boundary; then
gateway_control_fail "$HERMES_RESTART_FAILURE_CODE" "$old_pid"
return 1
fi
if [ "$HERMES_MCP_RECONCILE_PENDING" -eq 1 ]; then
gateway_control_fail mcp-reconcile-required "$old_pid"
return 1
fi
if ! gateway_control_pid_is_live "$old_pid" \
|| ! hermes_gateway_healthy "$old_pid" \
|| hermes_auxiliaries_need_recovery; then
Expand All @@ -2660,8 +2660,8 @@ handle_hermes_gateway_control_request() {
&& gateway_control_pid_is_live "$old_pid" \
&& hermes_gateway_healthy "$old_pid"; then
# Recovery may also recreate the dashboard from the shared Hermes config.
# Verify the root-owned trust anchor before any auxiliary consumes it; a
# healthy gateway is not authority to bless direct sandbox config drift.
# Adopt one stable snapshot before any auxiliary consumes current config;
# the old gateway's health does not prove that snapshot stayed unchanged.
if ! prepare_hermes_gateway_restart; then
if hermes_restart_failure_revokes_gateway "$HERMES_RESTART_FAILURE_CODE"; then
stop_hermes_gateway_fail_closed
Expand Down Expand Up @@ -2809,10 +2809,9 @@ prepare_hermes_nonroot_runtime() {
# startup mutations below so their outputs remain covered as well.
validate_hermes_env_secret_boundary || return 1
# The non-root Hermes runtime can persist safe config/env changes while it is
# running. Reconcile that mutable compatibility anchor only after the secret
# boundary is valid; refresh-hashes still requires the recorded MCP intent to
# match exactly before it advances the anchor.
refresh_hermes_runtime_config_hashes compat || return 1
# running. Adopt one stable snapshot only after the secret boundary is valid.
# Direct MCP drift becomes pending until the replacement gateway is healthy.
refresh_hermes_runtime_config_hashes compat adopt || return 1
inspect_hermes_mcp_integrity "${HERMES_DIR}/.config-hash" || return 1
prepare_hermes_lazy_dependencies || return 1
ensure_hermes_runtime_api_server_key compat || return 1
Expand Down Expand Up @@ -2920,7 +2919,10 @@ publish_hermes_root_runtime_marker() {
}

prepare_hermes_root_runtime() {
verify_hermes_config_integrity || return 1
validate_hermes_env_secret_boundary || return 1
validate_hermes_runtime_env_secret_boundary || return 1
refresh_hermes_runtime_config_hashes both adopt || return 1
inspect_hermes_mcp_integrity "$HERMES_HASH_FILE" || return 1
prepare_hermes_lazy_dependencies || return 1
ensure_hermes_config_root_mode || return 1
ensure_hermes_runtime_api_server_key both || return 1
Expand Down Expand Up @@ -3075,26 +3077,27 @@ record_hermes_managed_gateway_exit() {
HERMES_MANAGED_GATEWAY_EXIT_TIMES=("${retained[@]+"${retained[@]}"}")
HERMES_MANAGED_GATEWAY_EXIT_COUNT=${#HERMES_MANAGED_GATEWAY_EXIT_TIMES[@]}
if [ "$HERMES_MANAGED_GATEWAY_EXIT_COUNT" -ge 5 ]; then
echo "[gateway] CRITICAL: $HERMES_MANAGED_GATEWAY_EXIT_COUNT exits in 60s window — Hermes relaunch is quarantined until sandbox recreation; check /tmp/gateway.log" >&2
echo "[gateway] CRITICAL: $HERMES_MANAGED_GATEWAY_EXIT_COUNT exits in 60s window — Hermes relaunch is stopped for this supervisor instance; correct the reported failure, then stop and start the sandbox; check /tmp/gateway.log" >&2
quarantine_hermes_managed_gateway_relaunch
return 1
fi
}

recover_hermes_gateway_current_user() {
local replacement_reached_internal_health
local replacement_reached_internal_health preparation_failures=0 preparation_failure_limit=5

while :; do
replacement_reached_internal_health=0
until prepare_hermes_nonroot_runtime; do
if [ "$HERMES_MCP_INTEGRITY_FAILED" -eq 1 ]; then
echo "[SECURITY] Hermes automatic respawn is quarantined until MCP integrity is restored by rebuilding the sandbox" >&2
quarantine_hermes_managed_gateway_relaunch
preparation_failures=$((preparation_failures + 1))
if [ "$preparation_failures" -ge "$preparation_failure_limit" ]; then
echo "[gateway] Hermes runtime preparation failed after ${preparation_failures} consecutive attempts; supervisor exiting without launching a gateway; correct the reported failure, then stop and start the sandbox" >&2
return 1
fi
echo "[gateway] Hermes runtime preparation refused automatic respawn; retrying in 5s" >&2
sleep 5 || true
done
preparation_failures=0
if ! launch_hermes_gateway_current_user; then
echo "[gateway] Hermes gateway launch failed; retrying under the same supervisor" >&2
sleep 5 || true
Expand Down
4 changes: 2 additions & 2 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"src/lib/actions/sandbox/gateway-state.ts": 21,
"src/lib/actions/sandbox/status-snapshot.ts": 19,
"src/lib/actions/sandbox/policy-channel.ts": 30,
"src/lib/actions/sandbox/process-recovery.ts": 20,
"src/lib/actions/sandbox/process-recovery.ts": 19,
"src/lib/actions/sandbox/rebuild-pipeline.ts": 30,
"src/lib/actions/sandbox/snapshot.ts": 37,
"src/lib/actions/uninstall/run-plan.ts": 25,
Expand All @@ -62,7 +62,7 @@
"maxRootFiles": {
"src/lib/onboard": 301,
"src/lib/actions": 18,
"src/lib/actions/sandbox": 178,
"src/lib/actions/sandbox": 177,
"src/lib/state": 38,
"src/lib/inference": 63,
"scripts": 42
Expand Down
19 changes: 13 additions & 6 deletions docs/manage-sandboxes/gateway-lifecycle-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ For `recover` and `gateway restart`, the managed controller acquires the expecte
Lock acquisition, gateway termination, and replacement health share one recovery deadline.
If lock acquisition reaches that deadline, the controller returns `SUPERVISOR_BUSY` without publishing an expected-exit marker.

Mutable managed config retains the trust and time-of-check/time-of-use limits of managed cold start.
<AgentOnly variant="hermes">
In the direct root-entrypoint topology, PID 1 validates the Hermes secret boundary and runtime environment and verifies the strict root-owned hash without recomputing it.
For Hermes, the managed controller preflights the secret boundary before it signals the observed child.

Mutable config in the managed topology has no durable root-owned hash anchor, so a restart cannot promise a `config hash mismatch` for direct drift.
Hermes configuration remains mutable in both topologies.
Before a restart, the supervisor validates the secret boundary and records one stable config snapshot in its transaction metadata.
A direct MCP change becomes pending and supersedes stale host-managed intent.
The supervisor marks that change as applied only after the replacement gateway passes its health checks.
The host operation that owns a managed MCP transaction can report a registry mismatch after Hermes is healthy.

The direct root-entrypoint topology keeps the integrity metadata under root ownership while it seals a restart transaction.
That metadata does not make the complete Hermes config a relaunch allowlist.
The managed topology has no durable root-owned config anchor because the supervisor, gateway, and agent share one UID.
</AgentOnly>

This compatibility path remains necessary while the OpenShell-managed topology owns a nonroot supervisor and shared gateway-agent UID.
Expand All @@ -50,9 +54,12 @@ Custom agents that recover through an SSH script do not use this controller prob
The nonroot Hermes supervisor continuously repairs the gateway, API relay, dashboard, dashboard relay, and gateway log stream.
Four consecutive gateway health failures trigger recovery of the observed gateway child.

Five unexpected gateway exits or failed replacement candidates within 60 seconds quarantine relaunch until the sandbox is recreated.
Five unexpected gateway exits or failed replacement candidates within 60 seconds stop relaunch for the current supervisor instance.
An authenticated host action authorizes one exit bound to the gateway process ID and kernel start identity while the root controller process remains live, so deliberate `gateway restart` and controller-driven replacement do not consume that crash budget.

Correct the reported process or health failure, then stop and start the sandbox to reset the supervisor.
Rebuild only if the sandbox still cannot start.

The authorization records host intent for that exit; it does not claim that the host signal was the only possible cause of process termination in the shared-UID topology.
After the in-sandbox processes are healthy, the host repairs only the host-side OpenShell forwards.
</AgentOnly>
Expand Down
6 changes: 5 additions & 1 deletion docs/manage-sandboxes/recover-rebuild-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@ $$nemoclaw <sandbox-name> gateway restart

The restart command asks the topology-specific controller to stop the tracked gateway child, wait for the entrypoint to launch a replacement, and prove listener and HTTP health. The host then checks or recovers host-side dashboard, messaging, and agent forwards.

For Hermes, the entrypoint supervisor owns the gateway, dashboard process, internal API relay, dashboard relay, and gateway log stream. The nonroot managed supervisor repairs those processes continuously, stops an alive but deaf gateway after four consecutive failed health checks, and quarantines relaunch after five exits within 60 seconds until sandbox recreation.
For Hermes, the entrypoint supervisor owns the gateway, dashboard process, internal API relay, dashboard relay, and gateway log stream.
The nonroot managed supervisor repairs those processes continuously and stops an alive but deaf gateway after four consecutive failed health checks.
Five unexpected gateway exits or failed replacement candidates within 60 seconds stop relaunch for the current supervisor instance. Host-authorized exits do not consume this crash budget.
Correct the reported failure, then run `$$nemoclaw <sandbox-name> stop` and `$$nemoclaw <sandbox-name> start` to reset the supervisor.
Rebuild only if the sandbox still cannot start.

The host does not start the in-sandbox processes independently. Refer to [`$$nemoclaw <name> recover`](../../reference/commands#$$nemoclaw-name-recover) and [`$$nemoclaw <name> gateway restart`](../../reference/commands#$$nemoclaw-name-gateway-restart) for details.

Expand Down
Loading
Loading