fix: explain the launchctl registration restriction without inventing KeepAlive

This commit is contained in:
Teknium
2026-09-07 02:31:22 -07:00
parent 4fbb253904
commit 57c60f2e0c
3 changed files with 62 additions and 4 deletions
@@ -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"]
+6 -4
View File
@@ -213,10 +213,12 @@ def gateway_lifecycle_block(
# oversized roots never reach shlex here. # oversized roots never reach shlex here.
if lifecycle_scan_root_within_budget(command) and contains_launchctl_submit_command(command): if lifecycle_scan_root_within_budget(command) and contains_launchctl_submit_command(command):
return _blocked_json( return _blocked_json(
"Blocked: launchctl submit/bootstrap registers a persistent " "Blocked: launchctl submit/bootstrap is restricted inside a supervised "
"KeepAlive job and is unsafe from inside the gateway process. " "gateway regardless of the job label, to prevent indirect gateway "
"Use Hermes cron for one-shot delayed work, or install an " "restart loops. This guard does not inspect the job's KeepAlive settings "
"explicit LaunchAgent from a separate shell.", "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", "error",
) )
guard_cwd_base = get_session_cwd(session_key) guard_cwd_base = get_session_cwd(session_key)
+25
View File
@@ -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). 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) ### 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: Some commands are so catastrophic — irreversible filesystem wipes, fork bombs, direct block-device writes — that Hermes refuses to run them **regardless** of: