security(approval): close launchctl / hermes CLI self-termination gaps - #7817
security(approval): close launchctl / hermes CLI self-termination gaps#7817BrownBear127 wants to merge 1 commit into
Conversation
Three additional DANGEROUS_PATTERNS entries closing self-termination
vectors not covered by the existing pkill/killall name-based pattern or
the pgrep-expansion patterns from #<aedf6c79>.
1. **macOS launchctl service control** — `launchctl kickstart -k` sends
SIGTERM to the target service. If the agent targets its own gateway
service, launchd KeepAlive (`SuccessfulExit: false`) respawns the
process, the resumed session sees a pending tool call with exit 130,
and the model retries the same command. Result: infinite self-kill
loop. Covers kickstart/kill/bootout/bootstrap and load/unload when
targeting a hermes service.
2. **hermes CLI gateway control** — `hermes -p <profile> gateway
restart/stop/kill` is the same class of self-termination when invoked
from inside the gateway. Upstream #<a55c044c> already routes this to
SIGUSR1 graceful restart at the CLI layer by detecting gateway
ancestry, but defense-in-depth at the approval layer catches the raw
pattern before it reaches that path — and also catches `stop`/`kill`
subcommands that graceful restart does not.
Motivating incident (2026-04-11):
The ade profile ran `launchctl kickstart -k gui/$(id -u)/ai.hermes.
gateway-ade` as part of a todo plan to sync a new slash command.
Launchd restarted the process, resume saw the pending launchctl call,
the model retried, loop for ~14 hours until a human noticed. The
command bypassed all existing patterns because macOS `launchctl` is
not covered and `hermes gateway restart` (which the model also tried)
was not in the pkill/killall pattern.
Tests: 12 new cases in TestGatewayProtection covering positive and
negative paths:
- 7 launchctl cases (kickstart, kickstart with $(id -u), kill,
bootout, unload+hermes, list negative, print negative)
- 5 hermes-CLI cases (restart, stop, kill, start negative, status
negative)
`systemctl --user restart hermes-gateway` is intentionally NOT added —
the existing `test_systemctl_restart_not_flagged` treats it as the
supported Linux restart path, and the CLI-layer fix in #<a55c044c>
already handles self-invocation via SIGUSR1.
Full suite: 131 passed, 0 failed.
|
Thanks for addressing both architectural gaps in #9895 and #9941 — the stuck-loop detection in particular is a much stronger fix than pattern matching alone. #9895 covers This PR adds those macOS patterns + 12 tests. Happy to rebase onto current main if there are conflicts after #9895. Also happy to close if you'd prefer to fold these into a follow-up. |
|
merge conflicts This PR does not merge cleanly with the base branch. Please rebase or merge current Signed: GPT-5.5-medium in Codex |
|
Thanks for this, and for the detailed incident writeup — the 14-hour ade-profile self-kill loop is exactly the kind of thing we want guarded. Closing in favor of #55515 (merged: #55515), which addresses the one genuine residual gap your report surfaced. A couple of notes on why we narrowed the scope:
Appreciate the contribution — it pointed straight at the bug. |
Three additional
DANGEROUS_PATTERNSentries closing self-termination vectorsnot covered by the existing
pkill/killallname-based pattern or thepgrep-expansion patterns from #aedf6c79.What
macOS
launchctlservice control —launchctl kickstart -k <target>sends SIGTERM to the target service. If the agent targets its own gateway
service,
launchdKeepAlive (SuccessfulExit: false) respawns the process,the resumed session sees a pending tool call with exit 130, and the model
retries the same command. Result: infinite self-kill loop.
Covers
launchctl kickstart,kill,bootout,bootstrap, pluslaunchctl load/unloadwhen the argument references a hermes service.hermesCLI gateway control —hermes -p <profile> gateway restart/stop/killis the same class of self-termination when invokedfrom inside the gateway.
Note: #a55c044c already addresses this at the CLI layer by detecting
gateway ancestry and routing
hermes gateway restartto a SIGUSR1 gracefulrestart. This PR adds defense-in-depth at the approval layer so that:
stop/killsubcommands (which the CLI path doesn't turn intograceful restart) are also caught
Why — motivating incident
On 2026-04-11, an
adeprofile agent on macOS added a new Discord slashcommand and included this step in its todo plan:
The agent then executed:
launchctl kickstart -k gui/$(id -u)/ai.hermes.gateway-adelaunchctlsent SIGTERM to the running gateway. launchd respawned the gateway(
KeepAlive.SuccessfulExit = false). The new gateway loaded the session fromSQLite, saw the pending launchctl tool call with exit 130, and the codex model
decided to retry — reasoning (not unreasonably) that the command had been
interrupted and should be tried again.
This ran in a tight loop for ~14 hours. Each human message that arrived during
the loop was persisted into the session, giving the resumed model additional
context, but the model consistently returned to the same todo step and ran the
same command. The existing
DANGEROUS_PATTERNSdid not catchlaunchctl kickstart(not apkill/killall) orhermes gateway restart(doesn'tmatch the
hermes|gateway|cli.pyname-in-body pattern in apkill/killallcontext).
The incident is a cousin of the one that motivated #aedf6c79 —
same "self-termination" category, different primitive.
Why not
systemctlsystemctl --user restart hermes-gatewayis intentionally not added toDANGEROUS_PATTERNS. The existing testtest_systemctl_restart_not_flaggedtreats it as the supported Linux restart path, and #a55c044c already handles
self-invocation via SIGUSR1 at the CLI layer for
systemctl. Adding aDANGEROUS_PATTERNSentry would require removing that test, which would bea behavior change rather than a pure gap fix.
If maintainers disagree and would prefer
systemctl restart hermes-*to beflagged symmetric with
launchctl, I'm happy to add the pattern + update theexisting test in a follow-up commit.
Tests
12 new cases in
TestGatewayProtection, following the same positive + negativepattern as
TestPgrepKillExpansion:kickstart,kickstartwith$(id -u),kill,bootout,unload+hermes,list(negative),print(negative)restart,stop,kill,start(negative),status(negative)Full suite:
131 passed, 0 failed(119 before → 131 after).Checklist
pytest tests/tools/test_approval.pypasses