From 57c60f2e0c9073641b72812127fc9d7df7626470 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:31:22 -0700 Subject: [PATCH] fix: explain the launchctl registration restriction without inventing KeepAlive --- .../tools/test_launchctl_guard_diagnostic.py | 31 +++++++++++++++++++ tools/terminal_tool_guards.py | 10 +++--- website/docs/user-guide/security.md | 25 +++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 tests/tools/test_launchctl_guard_diagnostic.py diff --git a/tests/tools/test_launchctl_guard_diagnostic.py b/tests/tools/test_launchctl_guard_diagnostic.py new file mode 100644 index 0000000000..9aeba7f1a2 --- /dev/null +++ b/tests/tools/test_launchctl_guard_diagnostic.py @@ -0,0 +1,31 @@ +"""Diagnostics must distinguish policy rejection from inspected job properties.""" + +import json +import plistlib + +from tools.terminal_tool_guards import gateway_lifecycle_block + + +def test_bootstrap_rejection_does_not_invent_keepalive(tmp_path, monkeypatch): + from tools import process_registry + + monkeypatch.setattr(process_registry, "_is_supervised_gateway_process", lambda: True) + plist = tmp_path / "com.example.schedule.plist" + plist.write_bytes(plistlib.dumps({ + "Label": "com.example.schedule", + "ProgramArguments": ["/bin/true"], + "RunAtLoad": False, + "StartCalendarInterval": {"Hour": 9, "Minute": 0}, + })) + blocked = gateway_lifecycle_block( + command=f"launchctl bootstrap gui/501 {plist}", + env=None, env_type="local", cwd=str(tmp_path), workdir=None, + session_key="diagnostic-test", + ) + assert blocked is not None + result = json.loads(blocked) + assert result["exit_code"] == 1 + assert "regardless of the job label" in result["error"] + assert "does not inspect" in result["error"] + assert "KeepAlive settings" in result["error"] + assert "separate shell outside the gateway" in result["error"] diff --git a/tools/terminal_tool_guards.py b/tools/terminal_tool_guards.py index 24940a0379..87823320d4 100644 --- a/tools/terminal_tool_guards.py +++ b/tools/terminal_tool_guards.py @@ -213,10 +213,12 @@ def gateway_lifecycle_block( # oversized roots never reach shlex here. if lifecycle_scan_root_within_budget(command) and contains_launchctl_submit_command(command): return _blocked_json( - "Blocked: launchctl submit/bootstrap registers a persistent " - "KeepAlive job and is unsafe from inside the gateway process. " - "Use Hermes cron for one-shot delayed work, or install an " - "explicit LaunchAgent from a separate shell.", + "Blocked: launchctl submit/bootstrap is restricted inside a supervised " + "gateway regardless of the job label, to prevent indirect gateway " + "restart loops. This guard does not inspect the job's KeepAlive settings " + "or determine whether it is independent of Hermes. Perform authorized " + "LaunchAgent maintenance from a separate shell outside the gateway, " + "not by switching launchctl verbs to bypass this rejection.", "error", ) guard_cwd_base = get_session_cwd(session_key) diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index a5f619d4ff..bedd4bcb47 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -93,6 +93,31 @@ YOLO mode disables **all** dangerous command safety checks for the session — * For destructive session slash commands (`/clear`, `/new` / `/reset`, `/undo`, `/quit --delete` — `/exit --delete` is an alias), the CLI also prompts for confirmation before running them. See [Slash Commands — Confirmation prompts for destructive commands](../reference/slash-commands.md#confirmation-prompts-for-destructive-commands). +### Supervised-gateway lifecycle restriction + +The terminal tool has a separate, non-overridable guard against stopping or +restarting the gateway from inside its own supervised process. A self-restart can +terminate the tool before it finishes and cause a supervisor/auto-resume loop. +User approval, YOLO mode, and `force=True` do not bypass this guard. + +On macOS, executed `launchctl submit` and `launchctl bootstrap` commands are +restricted **regardless of the job label**. This is a conservative registration +restriction intended to catch indirect restart helpers with neutral labels, not +an inspection of the target plist. It also rejects independent scheduled jobs +with `RunAtLoad=false` and no `KeepAlive` key; rejection does **not** establish that +the job uses KeepAlive or controls Hermes. + +For authorized LaunchAgent maintenance, use a separate shell outside the running +gateway. Some independent `load`/`unload` commands currently pass the label-based +checks, but that is not a target-verified exemption or a supported way to evade a +`bootstrap` rejection. Read-only `launchctl print` is not a lifecycle operation. +After external maintenance, distinguish the on-disk plist from the loaded job: +validate the plist and read back the loaded schedule before reporting activation. + +A tool rejection means the command did not execute through that tool call. An +assistant declining to issue a call is a separate model decision; changing models +does not change the terminal guard's policy. + ### Hardline Blocklist (Always-On Floor) Some commands are so catastrophic — irreversible filesystem wipes, fork bombs, direct block-device writes — that Hermes refuses to run them **regardless** of: