2117 lines
92 KiB
Python
2117 lines
92 KiB
Python
"""Tests for gateway restart-loop defenses (#30719).
|
|
|
|
Covers:
|
|
- Defense 1: gateway stop/restart refuse when _HERMES_GATEWAY=1
|
|
- Defense 2: cron create rejects prompts containing gateway lifecycle commands
|
|
- _contains_gateway_lifecycle_command pattern matching
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from argparse import Namespace
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.cron import (
|
|
_contains_gateway_lifecycle_command,
|
|
cron_command,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defense 2: _contains_gateway_lifecycle_command pattern tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGatewayLifecyclePattern:
|
|
"""Verify the regex catches gateway lifecycle commands."""
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
"hermes gateway restart",
|
|
"hermes gateway stop",
|
|
"hermes gateway uninstall",
|
|
"hermes gateway restart", # double spaces
|
|
"Hermez Gateway Restart".lower().replace("z", "s"), # case handled
|
|
"HERMES GATEWAY RESTART", # uppercase
|
|
])
|
|
def test_hermes_gateway_commands(self, text):
|
|
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
# #62891: a blocked direct restart/kill laundered through a NEW
|
|
# launchd keepalive job wrapping a helper script, instead of a
|
|
# direct kickstart/unload/stop/restart on the existing service.
|
|
"launchctl submit -l ai.hermes.gateway-hard-restart-no-photon-notice -- /bin/sh ~/.hermes/scripts/hard_restart_gateway_no_photon_notice.sh",
|
|
"launchctl submit -l hermes-gateway-restart-helper -- /bin/sh helper.sh",
|
|
# bootstrap loads an arbitrary plist — same laundering shape.
|
|
"launchctl bootstrap gui/501 ~/Library/LaunchAgents/ai.hermes.gateway.restart-once.plist",
|
|
"launchctl bootout gui/501/ai.hermes.gateway",
|
|
# The exact reported shape: split across shell line-continuations
|
|
# (`\` immediately followed by a newline). `[^\n]*` alone can't span
|
|
# that, so the verb and the gateway-label token land on different
|
|
# physical lines unless continuations are normalized first.
|
|
(
|
|
"launchctl submit \\\n"
|
|
" -l ai.hermes.gateway-hard-restart-no-photon-notice \\\n"
|
|
" -- /bin/sh ~/.hermes/scripts/hard_restart_gateway_no_photon_notice.sh"
|
|
),
|
|
])
|
|
def test_launchctl_submit_bootstrap_commands(self, text):
|
|
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
|
|
|
def test_launchctl_bootout_verb_is_caught(self):
|
|
"""`bootout` was missing from Branch B's verb list entirely — the
|
|
2026-08-02 incident command used exactly this verb (it deregisters
|
|
the job outright, unlike stop/kickstart) and slipped past both this
|
|
check and the missing-verb rule in tools/approval.py."""
|
|
assert _contains_gateway_lifecycle_command(
|
|
"launchctl bootout gui/501/ai.hermes.gateway"
|
|
)
|
|
|
|
def test_label_defined_before_verb_is_caught(self):
|
|
"""2026-08-02 incident reproduction: the label comes from a shell
|
|
for-loop list defined BEFORE the launchctl call, in an earlier `;`
|
|
-separated segment, referenced only via `$label` at the point of the
|
|
verb. Branch B's `[^\\n]*` sequential match requires the literal
|
|
label text to appear AFTER the verb IN THE SAME SEGMENT and never
|
|
sees it — restarted 4 gateways with zero approval."""
|
|
cmd = (
|
|
"uid=$(id -u); for item in 'ai.hermes.gateway-apollo:/a.plist' "
|
|
"'ai.hermes.gateway-cronus:/c.plist' 'ai.hermes.gateway-plutus:/p.plist' "
|
|
"'ai.hermes.gateway:/Users/botuser/Library/LaunchAgents/ai.hermes.gateway.plist'; "
|
|
"do label=${item%%:*}; plist=${item#*:}; "
|
|
'launchctl bootout "gui/$uid/$label"; '
|
|
'launchctl bootstrap "gui/$uid" "$plist"; done'
|
|
)
|
|
assert _contains_gateway_lifecycle_command(cmd)
|
|
|
|
def test_line_continuation_does_not_bridge_unrelated_lines(self):
|
|
# A backslash-newline is only normalized when it's a real shell
|
|
# continuation. Two genuinely separate lines of a longer prompt
|
|
# (no trailing backslash) must not be bridged into a false match.
|
|
text = (
|
|
"this restarts the payment gateway\n"
|
|
"unrelated hermes note on the next line"
|
|
)
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
# #80269: the shell resolves quote-splicing and backslash-escaping
|
|
# into a single literal word BEFORE the command runs, so
|
|
# `launchctl kick"start" ... ai.hermes.gateway` executes exactly as
|
|
# the blocked `kickstart` form. Raw-text matching sees the quote (or
|
|
# backslash) wedged between the verb's halves and misses it, leaving
|
|
# the bypassable approval layer as the only cover.
|
|
'launchctl kick"start" -k gui/501/ai.hermes.gateway',
|
|
"launchctl kick'start' -k gui/501/ai.hermes.gateway",
|
|
"launchctl kick\\start -k gui/501/ai.hermes.gateway",
|
|
'launchctl "kickstart" -k gui/501/ai.hermes.gateway',
|
|
# Splices on the newer/legacy unload spellings this PR added.
|
|
'launchctl boot"out" gui/501/ai.hermes.gateway',
|
|
"launchctl dis\\able gui/501/ai.hermes.gateway",
|
|
# The gateway identifier itself can be spliced just as easily.
|
|
'launchctl bootout gui/501/ai.hermes."gateway"',
|
|
# Same class on the systemctl and hermes-CLI branches.
|
|
'systemctl re"start" hermes-gateway',
|
|
'hermes gateway re"start"',
|
|
])
|
|
def test_shell_token_spliced_lifecycle_verbs(self, text):
|
|
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
|
|
|
def test_spliced_verb_inside_shell_c_payload_is_blocked(self):
|
|
# A splice nested in a `sh -c` payload resolves one level deeper than
|
|
# the flat scan: POSIX single quotes preserve the inner double quotes
|
|
# verbatim, so the outer tokenization yields the payload with the
|
|
# splice still intact. The recursion re-scans that payload through the
|
|
# same choke point, where it collapses to `kickstart`. This is the
|
|
# entry point terminal_tool.py calls in gateway sessions, so it is the
|
|
# boundary that matters.
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
|
|
command = 'sh -c \'launchctl kick"start" -k gui/501/ai.hermes.gateway\''
|
|
assert contains_gateway_lifecycle_command_or_referenced_script(command)
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
# The tokenizing pass must not widen the blast radius: prose and
|
|
# non-gateway services stay allowed even though tokenization now
|
|
# strips their quotes too.
|
|
'echo "restart the payment gateway"',
|
|
'launchctl kick"start" -k gui/501/ai.hermes.update-checker',
|
|
'systemctl re"start" hermes-meta.service',
|
|
"Summarize how the API gateway handles a restart after rate limiting",
|
|
])
|
|
def test_tokenizing_pass_does_not_overmatch(self, text):
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}"
|
|
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
"restart the server application",
|
|
"hermes cron list",
|
|
"hermes update",
|
|
"hermes config set model claude",
|
|
"echo 'just a normal cron job'",
|
|
"run the backup script",
|
|
"gateway is running fine",
|
|
# `hermes gateway start` is benign — starting a gateway from inside a
|
|
# gateway is a no-op / "already running", and a legit cron job may
|
|
# start a sibling profile's gateway. Only restart/stop/kill are the
|
|
# foot-gun (#30719 lists only those).
|
|
"hermes gateway start",
|
|
"hermes gateway start --all",
|
|
# Tightened launchctl/systemctl branches: ops on NON-gateway hermes
|
|
# services must not be falsely blocked (the old `.*hermes` matched any
|
|
# hermes token).
|
|
"launchctl unload ai.hermes.update-checker.plist",
|
|
"launchctl restart ai.hermes.daemon",
|
|
# `submit` on an unrelated launchd label must not match the text
|
|
# pattern (a cron PROMPT is prose fed to an LLM). The execution-aware
|
|
# `contains_launchctl_submit_command` handles neutral-label submits
|
|
# at the terminal/cron-script chokepoints instead.
|
|
"launchctl submit -l com.example.backup -- /bin/sh backup.sh",
|
|
"systemctl restart hermes-meta.service",
|
|
"systemctl restart hermes-cron-helper",
|
|
# Regression (#30728 follow-up): legit prompts that merely mention an
|
|
# unrelated gateway + a restart must NOT be blocked. The cron prompt is
|
|
# fed to an LLM, not a shell, so substring detection on English text is
|
|
# a high-FP no-op — only concrete command shapes trigger the block.
|
|
"Summarize the API gateway logs and report any restart events from last night",
|
|
"Check if the payment gateway needs a restart after the deploy",
|
|
"Monitor the gateway and tell me if a restart is recommended",
|
|
"research how the OpenAI API gateway handles restart after rate limiting",
|
|
"compare AWS API Gateway vs Cloudflare on restart latency",
|
|
# #92372 Branch A: no trailing boundary meant ordinary prose matched —
|
|
# "restarted" carries the "restart" prefix and the old pattern ended
|
|
# exactly there. \b after the verb group fixes it.
|
|
"echo after the hermes gateway restarted cleanly",
|
|
"the hermes gateway stopped responding, please investigate",
|
|
# #92372 Branch D: `p?kill` without a leading \b matched the "kill"
|
|
# tail of "skill".
|
|
"hermes skill view gateway-notes && echo hermes gateway docs",
|
|
# #77173/#77536: a file path with embedded spaces containing the
|
|
# lifecycle words must not match — `hermes` is a path component
|
|
# there, not a command.
|
|
"cat '/docs/hermes gateway restart-notes.md'",
|
|
"less /home/user/notes/hermes gateway restart runbook.txt",
|
|
])
|
|
def test_safe_commands(self, text):
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
# Trailing-boundary fix must not weaken real commands.
|
|
"hermes gateway restart",
|
|
"hermes gateway restart; echo done",
|
|
"hermes gateway stop && echo stopped",
|
|
# #77173 command-position anchor must not weaken separator/subshell
|
|
# forms either.
|
|
"true;hermes gateway restart",
|
|
"true && hermes gateway stop",
|
|
"echo $(hermes gateway restart)",
|
|
"echo `hermes gateway restart`",
|
|
])
|
|
def test_boundary_fix_still_blocks_real_commands(self, text):
|
|
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
|
|
|
def test_quoted_multiline_payload_tokenizes_as_one_logical_line(self):
|
|
# #92372: a newline inside a quoted string is data, not a command
|
|
# separator. A quoted data-file path on its own physical line inside
|
|
# a multiline construct must not be promoted to command position.
|
|
text = (
|
|
'FILES=(\n'
|
|
' "/tmp/notes about procedures.txt"\n'
|
|
')\n'
|
|
'echo "${FILES[@]}"'
|
|
)
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}"
|
|
|
|
def test_unbalanced_quotes_still_scanned_not_waved_through(self):
|
|
# Fail-closed contract: when the logical line cannot tokenize
|
|
# (unbalanced quote), the per-physical-line fallback must still SCAN
|
|
# the content — a lifecycle command alongside an unbalanced quote
|
|
# must remain blocked, never waved through.
|
|
text = 'echo "unbalanced\nhermes gateway restart'
|
|
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
# #68289: execute_code payloads carry the argv as a Python list —
|
|
# brackets/commas separate the words the OS will exec.
|
|
'import subprocess\nsubprocess.run(["launchctl", "bootout", "gui/501/ai.hermes.gateway"])',
|
|
'subprocess.run(["hermes", "gateway", "restart"])',
|
|
'os.system("launchctl kickstart -k gui/501/ai.hermes.gateway")',
|
|
])
|
|
def test_python_argv_list_forms_blocked(self, text):
|
|
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
# Argv-punctuation stripping must not create prose false positives.
|
|
'print("checking gateway restart docs")',
|
|
'data = ["hermes", "notes"] # unrelated list',
|
|
])
|
|
def test_python_argv_stripping_stays_narrow(self, text):
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}"
|
|
|
|
def test_inert_heredoc_body_prose_not_blocked(self):
|
|
# #88336: a quoted-delimiter heredoc feeding a data sink is inert
|
|
# data — runbook prose inside it must not block.
|
|
text = (
|
|
"cat > /tmp/runbook.md <<'EOF'\n"
|
|
"If the box is wedged, a human can run: hermes gateway restart\n"
|
|
"EOF"
|
|
)
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
# Executable heredoc (shell consumer) must stay blocked.
|
|
"bash <<EOF\nhermes gateway restart\nEOF",
|
|
# Unquoted delimiter = expansion-capable = fail open to scanning.
|
|
"cat > /tmp/x <<EOF\nhermes gateway restart\nEOF",
|
|
])
|
|
def test_non_inert_heredocs_still_scanned(self, text):
|
|
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
|
|
|
|
|
class TestProfileFlagGatewayLifecycle:
|
|
"""#78028: `hermes -p <profile> gateway restart|stop` bypasses Branch A's
|
|
literal adjacency, so it needs its own pattern. It is only the same
|
|
self-termination foot-gun when the named profile IS the profile running
|
|
the guard; sibling-profile restarts are legitimate fleet operations and
|
|
must stay allowed."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _pin_profile_identity(self, monkeypatch):
|
|
# The ambient test env may carry HERMES_HOME/HERMES_PROFILE; pin the
|
|
# profile identity explicitly so every assertion is deterministic.
|
|
monkeypatch.setenv("HERMES_PROFILE", "zeus")
|
|
monkeypatch.delenv("HERMES_PROFILE_NAME", raising=False)
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
"hermes -p zeus gateway stop",
|
|
"hermes -p zeus gateway restart",
|
|
"hermes --profile zeus gateway restart",
|
|
"hermes --profile zeus gateway stop",
|
|
"hermes --profile=zeus gateway restart",
|
|
# Global flags before/after the selector must not hide the shape.
|
|
"hermes -v -p zeus gateway restart",
|
|
"hermes -p zeus -v gateway restart",
|
|
"hermes --debug --profile zeus gateway stop",
|
|
# Shell quoting of the profile id is equivalent to the bare name.
|
|
"hermes -p 'zeus' gateway restart",
|
|
"hermes --profile \"zeus\" gateway stop",
|
|
])
|
|
def test_self_target_blocked(self, text):
|
|
assert _contains_gateway_lifecycle_command(text), f"Should block: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
"hermes -p venus gateway stop",
|
|
"hermes -p venus gateway restart",
|
|
"hermes --profile venus gateway restart",
|
|
"hermes --profile=venus gateway stop",
|
|
"hermes -p venus -v gateway restart",
|
|
])
|
|
def test_sibling_allowed(self, text):
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should allow: {text!r}"
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
"hermes -p zeus gateway start",
|
|
"hermes -p zeus gateway start --all",
|
|
])
|
|
def test_start_still_allowed(self, text):
|
|
# `start` is intentionally excluded from the guard, with or without
|
|
# the profile flag (#30719 rationale).
|
|
assert not _contains_gateway_lifecycle_command(text), f"Should allow: {text!r}"
|
|
|
|
def test_adjacent_form_still_blocked(self):
|
|
# Branch A remains unconditional — the profile-flag check is an
|
|
# additional layer, not a replacement.
|
|
assert _contains_gateway_lifecycle_command("hermes gateway restart")
|
|
assert _contains_gateway_lifecycle_command("hermes gateway stop")
|
|
|
|
def test_hermes_home_derived_profile(self, monkeypatch):
|
|
# Without HERMES_PROFILE the guard falls back to the HERMES_HOME-
|
|
# derived profile identity (get_active_profile_name) — the signal the
|
|
# gateway process itself carries.
|
|
monkeypatch.delenv("HERMES_PROFILE", raising=False)
|
|
monkeypatch.delenv("HERMES_PROFILE_NAME", raising=False)
|
|
import hermes_cli.profiles as profiles_mod
|
|
|
|
monkeypatch.setattr(profiles_mod, "get_active_profile_name", lambda: "zeus")
|
|
assert _contains_gateway_lifecycle_command("hermes -p zeus gateway restart")
|
|
assert not _contains_gateway_lifecycle_command("hermes -p venus gateway restart")
|
|
|
|
def test_no_profile_context_conservative_allow(self, monkeypatch):
|
|
# With no profile identity the guard cannot prove self-targeting, so
|
|
# the profile-flag form is allowed rather than over-blocking siblings;
|
|
# the adjacent form stays blocked unconditionally.
|
|
import cron.lifecycle_guard as lifecycle_guard
|
|
|
|
monkeypatch.setattr(lifecycle_guard, "_current_profile_name", lambda: None)
|
|
assert not _contains_gateway_lifecycle_command("hermes -p zeus gateway restart")
|
|
assert _contains_gateway_lifecycle_command("hermes gateway restart")
|
|
|
|
|
|
class TestCronCreateLifecycleBlock:
|
|
"""Verify cron create rejects gateway lifecycle prompts."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_cron_dir(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
|
|
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
|
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
|
|
|
|
def test_block_hermes_gateway_restart(self, capsys):
|
|
args = Namespace(
|
|
cron_command="create",
|
|
schedule="30m",
|
|
prompt="Upgrade hermes then run hermes gateway restart",
|
|
name=None,
|
|
deliver=None,
|
|
repeat=None,
|
|
skill=None,
|
|
skills=None,
|
|
script=None,
|
|
workdir=None,
|
|
profile=None,
|
|
no_agent=False,
|
|
)
|
|
rc = cron_command(args)
|
|
assert rc == 1
|
|
out = capsys.readouterr().out
|
|
assert "Blocked" in out
|
|
assert "#30719" in out
|
|
|
|
|
|
def test_block_script_with_lifecycle_command(self, tmp_path, capsys, monkeypatch):
|
|
# A no_agent job whose script IS the job (the issue's real abuse path:
|
|
# restart_hermes_gateway_once.sh). The script must live under
|
|
# HERMES_HOME/scripts so the scheduler — and the guard — resolve it.
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
scripts_dir = tmp_path / ".hermes" / "scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "restart.sh").write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
|
args = Namespace(
|
|
cron_command="create",
|
|
schedule="1h",
|
|
prompt=None,
|
|
name=None,
|
|
deliver=None,
|
|
repeat=None,
|
|
skill=None,
|
|
skills=None,
|
|
script="restart.sh",
|
|
workdir=None,
|
|
profile=None,
|
|
no_agent=True,
|
|
)
|
|
rc = cron_command(args)
|
|
assert rc == 1
|
|
out = capsys.readouterr().out
|
|
assert "Blocked" in out
|
|
|
|
|
|
def test_allow_empty_prompt(self, capsys):
|
|
"""Empty prompt (no lifecycle content) should pass the filter — the
|
|
API will still reject it for lacking prompt+skill, but that's a
|
|
separate validation, not the lifecycle guard."""
|
|
args = Namespace(
|
|
cron_command="create",
|
|
schedule="30m",
|
|
prompt=None,
|
|
name=None,
|
|
deliver=None,
|
|
repeat=None,
|
|
skill=None,
|
|
skills=None,
|
|
script=None,
|
|
workdir=None,
|
|
profile=None,
|
|
no_agent=False,
|
|
)
|
|
rc = cron_command(args)
|
|
# The lifecycle guard passes (no gateway command in prompt).
|
|
# The API rejects it for "requires prompt or skill" → rc 1, but
|
|
# the error message is about prompt/skill, NOT about "Blocked".
|
|
out = capsys.readouterr().out
|
|
assert "Blocked" not in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defense 1: gateway stop/restart refuse inside gateway
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGatewaySelfTargetingGuard:
|
|
"""Verify destructive gateway commands refuse inside the gateway."""
|
|
|
|
def test_stop_refuses_inside_gateway(self, monkeypatch):
|
|
from tools import process_registry
|
|
monkeypatch.setattr(
|
|
process_registry, "_is_supervised_gateway_process", lambda: True
|
|
)
|
|
from hermes_cli.gateway import gateway_command
|
|
args = Namespace(gateway_command="stop", all=False, system=False)
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
gateway_command(args)
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_uninstall_refuses_inside_gateway(self, monkeypatch):
|
|
from tools import process_registry
|
|
monkeypatch.setattr(
|
|
process_registry, "_is_supervised_gateway_process", lambda: True
|
|
)
|
|
from hermes_cli.gateway import gateway_command
|
|
|
|
args = Namespace(gateway_command="uninstall", system=False)
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
gateway_command(args)
|
|
assert exc_info.value.code == 1
|
|
|
|
|
|
def test_stop_allows_outside_gateway(self, monkeypatch):
|
|
# With the gateway marker unset, the self-targeting guard must NOT
|
|
# fire. Prove control reaches the real stop path (rather than driving
|
|
# real signal delivery, which would trip the live-system guard) by
|
|
# short-circuiting the first downstream call with a sentinel.
|
|
monkeypatch.delenv("_HERMES_GATEWAY", raising=False)
|
|
import hermes_cli.gateway as gw
|
|
|
|
class _Reached(Exception):
|
|
pass
|
|
|
|
def _sentinel(*a, **k):
|
|
raise _Reached()
|
|
|
|
monkeypatch.setattr(gw, "_dispatch_via_service_manager_if_s6", _sentinel)
|
|
monkeypatch.setattr(gw, "_dispatch_all_via_service_manager_if_s6", _sentinel)
|
|
args = Namespace(gateway_command="stop", all=False, system=False)
|
|
with pytest.raises(_Reached):
|
|
gw.gateway_command(args)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defense 3: terminal_tool hard-blocks gateway lifecycle commands inside gateway
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTerminalToolGatewayLifecycleGuard:
|
|
"""terminal_tool must refuse gateway lifecycle commands when _HERMES_GATEWAY=1.
|
|
|
|
Issue #37453: systemctl --user restart hermes-gateway runs as a child of the
|
|
gateway process. When systemd delivers SIGTERM the gateway kills its own
|
|
restart command mid-execution — the service may never restart. The guard
|
|
must fire before execution, unconditionally (force=True cannot bypass it).
|
|
"""
|
|
|
|
def _make_fake_env(self):
|
|
class _FakeEnv:
|
|
env = {}
|
|
def execute(self, command, **kwargs): # pragma: no cover
|
|
raise AssertionError("execute must not be reached")
|
|
return _FakeEnv()
|
|
|
|
def _minimal_config(self):
|
|
return {"env_type": "local", "cwd": "/tmp", "timeout": 60, "lifetime_seconds": 3600}
|
|
|
|
def _patch_env(self, monkeypatch, fake_env, *, inside_gateway: bool):
|
|
import tools.terminal_tool as tt
|
|
from tools import process_registry
|
|
eid = "default"
|
|
monkeypatch.setattr(tt, "_active_environments", {eid: fake_env})
|
|
monkeypatch.setattr(tt, "_last_activity", {eid: 0.0})
|
|
monkeypatch.setattr(tt, "_task_env_overrides", {})
|
|
monkeypatch.setattr(tt, "_get_env_config", self._minimal_config)
|
|
monkeypatch.setattr(
|
|
process_registry, "_is_supervised_gateway_process",
|
|
lambda: inside_gateway,
|
|
)
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
"systemctl restart hermes-gateway",
|
|
"systemctl --user restart hermes-gateway",
|
|
"systemctl stop hermes-gateway.service",
|
|
"hermes gateway restart",
|
|
"hermes gateway uninstall",
|
|
"launchctl kickstart gui/501/ai.hermes.gateway",
|
|
"launchctl bootout gui/501/ai.hermes.gateway",
|
|
# #62891 exact reported shape and its bootstrap sibling.
|
|
"launchctl submit -l ai.hermes.gateway-hard-restart-no-photon-notice -- /bin/sh ~/.hermes/scripts/hard_restart_gateway_no_photon_notice.sh",
|
|
"launchctl submit -l com.foo -- /path/gateway",
|
|
"launchctl bootstrap gui/501 ~/Library/LaunchAgents/ai.hermes.gateway.restart-once.plist",
|
|
"pkill -f hermes.*gateway",
|
|
])
|
|
def test_blocks_lifecycle_commands_inside_gateway(self, monkeypatch, cmd):
|
|
import tools.terminal_tool as tt
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=cmd))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "Blocked" in result["error"]
|
|
|
|
def test_force_true_cannot_bypass_block(self, monkeypatch):
|
|
import tools.terminal_tool as tt
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(
|
|
command="systemctl restart hermes-gateway", force=True
|
|
))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "Blocked" in result["error"]
|
|
|
|
def test_blocks_lifecycle_command_hidden_in_referenced_script(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
import tools.terminal_tool as tt
|
|
|
|
script = tmp_path / "delayed-ops.sh"
|
|
script.write_text("#!/bin/bash\nsleep 45\nhermes gateway restart\n", encoding="utf-8")
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=f"/bin/bash {script}"))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "referenced script" in result["error"]
|
|
|
|
def test_blocks_launchctl_submit_inside_gateway(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
script = tmp_path / "health-check.sh"
|
|
script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n", encoding="utf-8")
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(
|
|
command=(
|
|
"launchctl submit -l ai.hermes.delayed-ops -- "
|
|
f"/bin/bash {script}"
|
|
)
|
|
))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "KeepAlive" in result["error"]
|
|
|
|
def test_oversized_root_skips_launchctl_prescan_and_fails_closed(
|
|
self, monkeypatch
|
|
):
|
|
"""#78398: an over-budget root must never reach shlex — not even via
|
|
the launchctl pre-scan that runs before the full guard."""
|
|
import cron.lifecycle_guard as lifecycle_guard
|
|
import tools.terminal_tool as tt
|
|
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
monkeypatch.setattr(
|
|
lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 8, raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 8, raising=False
|
|
)
|
|
|
|
def explode_if_tokenized(*args, **kwargs):
|
|
raise AssertionError("over-budget root reached shlex")
|
|
|
|
monkeypatch.setattr(lifecycle_guard.shlex, "shlex", explode_if_tokenized)
|
|
|
|
result = json.loads(tt.terminal_tool(command="x" * 9))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "command or referenced script" in result["error"]
|
|
assert "KeepAlive" not in result["error"]
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# Neutral, non-hermes label: label-independent detection is the point
|
|
# (#62891 second reproduction used `ai.hermes.svc-reload-tmp`).
|
|
"launchctl submit -l com.foo -- /path/gateway",
|
|
"launchctl submit -l ai.hermes.svc-reload-tmp -- /bin/sh /tmp/h-svc-reload.sh",
|
|
# bootstrap variant: loads an arbitrary plist as a persistent job.
|
|
"launchctl bootstrap gui/501 /tmp/com.foo.plist",
|
|
])
|
|
def test_blocks_neutral_label_submit_and_bootstrap(self, monkeypatch, command):
|
|
import tools.terminal_tool as tt
|
|
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=command))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "KeepAlive" in result["error"]
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
"launchctl submit -l com.foo -- /path/gateway",
|
|
"launchctl bootstrap gui/501 /tmp/com.foo.plist",
|
|
])
|
|
def test_submit_and_bootstrap_allowed_outside_gateway(self, monkeypatch, command):
|
|
"""The label-independent block applies only inside the gateway process."""
|
|
import tools.terminal_tool as tt
|
|
|
|
calls = []
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
|
|
def execute(self, cmd, **kwargs):
|
|
calls.append(cmd)
|
|
return {"output": "", "returncode": 0}
|
|
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=False)
|
|
monkeypatch.setattr(
|
|
tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True}
|
|
)
|
|
|
|
result = json.loads(tt.terminal_tool(command=command))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls == [command]
|
|
|
|
def test_cli_agent_session_not_blocked_by_inherited_env(
|
|
self, monkeypatch
|
|
):
|
|
"""#92560: CLI/TUI agent sessions inherit _HERMES_GATEWAY=1 from the
|
|
gateway but are NOT the gateway supervisor. The env gate must not
|
|
fire for them — only for the actual gateway process (PID-file owner).
|
|
"""
|
|
import tools.terminal_tool as tt
|
|
|
|
calls = []
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
|
|
def execute(self, cmd, **kwargs):
|
|
calls.append(cmd)
|
|
return {"output": "", "returncode": 0}
|
|
|
|
# Simulate a CLI agent session: _HERMES_GATEWAY=1 is in the
|
|
# environment (inherited from the gateway), but
|
|
# _is_supervised_gateway_process() returns False because the
|
|
# process does not own the gateway PID file.
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=False)
|
|
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
|
monkeypatch.setattr(
|
|
tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True}
|
|
)
|
|
|
|
result = json.loads(tt.terminal_tool(command="hermes gateway restart"))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls == ["hermes gateway restart"]
|
|
|
|
def test_blocks_launchctl_submit_hidden_in_referenced_script(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
import tools.terminal_tool as tt
|
|
|
|
script = tmp_path / "wrapper.sh"
|
|
script.write_text(
|
|
"#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
|
|
)
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=f"/bin/bash {script}"))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "referenced script" in result["error"]
|
|
|
|
def test_relative_script_uses_live_session_cwd(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
script = tmp_path / "relative.sh"
|
|
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
cwd = str(tmp_path)
|
|
def execute(self, command, **kwargs): # pragma: no cover
|
|
raise AssertionError("execute must not be reached")
|
|
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command="/bin/bash relative.sh"))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "referenced script" in result["error"]
|
|
|
|
def test_blocks_executable_shebang_script(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
script = tmp_path / "delayed.sh"
|
|
script.write_text("#!/bin/bash\nhermes gateway stop\n", encoding="utf-8")
|
|
script.chmod(0o700)
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=str(script)))
|
|
|
|
assert result["exit_code"] == 1
|
|
|
|
def test_launchctl_submit_parser_handles_shell_quoting(self, monkeypatch):
|
|
import tools.terminal_tool as tt
|
|
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
result = json.loads(tt.terminal_tool(
|
|
command="launchctl sub\"\"mit -l ai.hermes.loop -- /bin/true"
|
|
))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "KeepAlive" in result["error"]
|
|
|
|
def test_shell_option_with_value_still_scans_script(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
script = tmp_path / "options.sh"
|
|
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(
|
|
command=f"/bin/bash -O extglob {script}"
|
|
))
|
|
|
|
assert result["exit_code"] == 1
|
|
|
|
def test_shell_c_payload_recursively_scans_script(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
script = tmp_path / "nested.sh"
|
|
script.write_text("#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n", encoding="utf-8")
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
cwd = str(tmp_path)
|
|
def execute(self, command, **kwargs): # pragma: no cover
|
|
raise AssertionError("execute must not be reached")
|
|
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(
|
|
command="/bin/bash -c '/bin/bash nested.sh'"
|
|
))
|
|
|
|
assert result["exit_code"] == 1
|
|
|
|
def test_nested_wrapper_script_is_scanned(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
inner = tmp_path / "inner.sh"
|
|
inner.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
|
outer = tmp_path / "outer.sh"
|
|
outer.write_text("#!/bin/bash\n/bin/bash inner.sh\n", encoding="utf-8")
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
cwd = str(tmp_path)
|
|
def execute(self, command, **kwargs): # pragma: no cover
|
|
raise AssertionError("execute must not be reached")
|
|
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=f"/bin/bash {outer}"))
|
|
|
|
assert result["exit_code"] == 1
|
|
|
|
def test_non_regular_referenced_script_fails_closed(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
fifo = tmp_path / "script.fifo"
|
|
os.mkfifo(fifo)
|
|
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=f"/bin/bash {fifo}"))
|
|
|
|
assert result["exit_code"] == 1
|
|
|
|
def test_quoted_launchctl_submit_text_is_not_blocked(self, monkeypatch):
|
|
import tools.terminal_tool as tt
|
|
|
|
calls = []
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
def execute(self, command, **kwargs):
|
|
calls.append(command)
|
|
return {"output": "launchctl submit is persistent", "returncode": 0}
|
|
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
|
|
monkeypatch.setattr(
|
|
tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True}
|
|
)
|
|
command = "printf '%s\\n' 'launchctl submit is persistent'"
|
|
|
|
result = json.loads(tt.terminal_tool(command=command))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls == [command]
|
|
|
|
def test_safe_referenced_script_passes_through(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
calls = []
|
|
script = tmp_path / "health-check.sh"
|
|
script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n", encoding="utf-8")
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
def execute(self, command, **kwargs):
|
|
calls.append(command)
|
|
return {"output": "healthy", "returncode": 0}
|
|
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
|
|
monkeypatch.setattr(
|
|
tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True}
|
|
)
|
|
command = f"/bin/bash {script}"
|
|
|
|
result = json.loads(tt.terminal_tool(command=command))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls == [command]
|
|
|
|
def test_safe_systemctl_commands_pass_through(self, monkeypatch):
|
|
"""Non-hermes systemctl commands must not be blocked by this guard."""
|
|
import tools.terminal_tool as tt
|
|
|
|
calls = []
|
|
|
|
class _FakeEnv:
|
|
env = {}
|
|
def execute(self, command, **kwargs):
|
|
calls.append(command)
|
|
return {"output": "Active: running", "returncode": 0}
|
|
|
|
self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
|
|
monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True})
|
|
|
|
result = json.loads(tt.terminal_tool(command="systemctl status nginx"))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls == ["systemctl status nginx"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cron.lifecycle_guard module — the shared checker create_job/CLI/terminal use
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLifecycleGuardModule:
|
|
"""Direct tests for cron.lifecycle_guard.check_gateway_lifecycle."""
|
|
|
|
def test_dot_operator_sourced_script_is_scanned(self, tmp_path):
|
|
"""`. ./script.sh` must reach the referenced-script scan.
|
|
|
|
The dot operator and `source` are the same POSIX builtin, but the
|
|
executable test compared only `Path(executable).name` — and
|
|
`Path(".").name` is the empty string, so `source` was caught while a
|
|
bare `.` slipped through and the sourced script was never scanned.
|
|
"""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
script = tmp_path / "restart.sh"
|
|
script.write_text("#!/bin/bash\nhermes gateway restart\n")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f". {script}")
|
|
is True
|
|
)
|
|
|
|
def test_nul_padded_script_is_still_scanned(self, tmp_path):
|
|
"""A NUL byte in a *text* script must not disable the scan.
|
|
|
|
The #76762 binary check treated any NUL in the first chunk as "compiled
|
|
binary, nothing to scan" — but ``bash`` executes a text script straight
|
|
past an embedded NUL, so one pad byte bypassed the guard entirely while
|
|
the script still ran its lifecycle command.
|
|
"""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
script = tmp_path / "padded.sh"
|
|
script.write_bytes(b"#!/bin/bash\n# pad\x00\nhermes gateway restart\n")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
|
|
is True
|
|
)
|
|
|
|
def test_source_builtin_sourced_script_is_scanned(self, tmp_path):
|
|
"""The `source` spelling must stay blocked (it already was)."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
script = tmp_path / "restart.sh"
|
|
script.write_text("#!/bin/bash\nhermes gateway restart\n")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f"source {script}")
|
|
is True
|
|
)
|
|
|
|
def test_dot_operator_clean_script_not_blocked(self, tmp_path):
|
|
"""Widening the dot check must not false-block an innocent sourced
|
|
script — e.g. sourcing a venv activate or an env file."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
script = tmp_path / "activate.sh"
|
|
script.write_text("#!/bin/bash\nexport PATH=/usr/bin:$PATH\n")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f". {script}")
|
|
is False
|
|
)
|
|
|
|
def test_nul_padded_script_without_shebang_is_scanned(self, tmp_path):
|
|
"""Same bypass without a shebang — bash still runs it, so still scan.
|
|
|
|
Keying the fix on a leading ``#!`` alone is insufficient: a shebang-less
|
|
file with a NUL on any line but the first executes normally.
|
|
"""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
script = tmp_path / "padded_noshebang.sh"
|
|
script.write_bytes(b"# ok\n# pad\x00\nhermes gateway restart\n")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
|
|
is True
|
|
)
|
|
|
|
def test_elf_binary_is_not_scanned_as_script(self, tmp_path):
|
|
"""#76762 must stay fixed: a real binary is nothing-to-scan, no crash.
|
|
|
|
Its decoded machine code must never be tokenized as shell text, and the
|
|
guard must not fail closed on an innocent interpreter invocation.
|
|
"""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
binary = tmp_path / "tool"
|
|
binary.write_bytes(b"\x7fELF\x02\x01\x01\x00" + b"\x00" * 64 + b"/usr/bin/x\x00")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f"{binary} --version")
|
|
is False
|
|
)
|
|
|
|
def test_macho_binary_is_not_scanned_as_script(self, tmp_path):
|
|
"""Same for Mach-O, including the universal/fat signature (macOS)."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
for name, magic in (
|
|
("macho64", b"\xcf\xfa\xed\xfe"),
|
|
("machofat", b"\xca\xfe\xba\xbe"),
|
|
):
|
|
binary = tmp_path / name
|
|
binary.write_bytes(magic + b"\x00" * 64)
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(
|
|
f"{binary} --version"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_oversized_nul_bearing_text_still_fails_closed(self, tmp_path):
|
|
"""An oversized *text* script must keep failing closed.
|
|
|
|
Stripping NULs must not let a too-large file skip the size guard — the
|
|
binary check runs first, the size check still applies afterwards.
|
|
"""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
script = tmp_path / "huge.sh"
|
|
script.write_bytes(b"#!/bin/bash\n# \x00" + b"x" * (1024 * 1024 + 64) + b"\n")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
|
|
is True
|
|
)
|
|
|
|
def test_clean_script_without_lifecycle_command_not_blocked(self, tmp_path):
|
|
"""Sanity: the change must not false-block innocent scripts."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
script = tmp_path / "safe.sh"
|
|
script.write_bytes(b"#!/bin/bash\necho hello\n")
|
|
assert (
|
|
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
|
|
is False
|
|
)
|
|
|
|
def test_prompt_with_command_raises(self):
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
with pytest.raises(GatewayLifecycleBlocked) as exc:
|
|
check_gateway_lifecycle("please run hermes gateway restart", None)
|
|
assert "#30719" in str(exc.value)
|
|
|
|
def test_clean_prompt_does_not_raise(self):
|
|
from cron.lifecycle_guard import check_gateway_lifecycle
|
|
check_gateway_lifecycle("research the gateway architecture", None)
|
|
check_gateway_lifecycle("check server health and restart watchers", None)
|
|
|
|
def test_script_with_command_raises(self, tmp_path, monkeypatch):
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
script = tmp_path / "restart.sh"
|
|
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("clean prompt", str(script))
|
|
|
|
def test_script_with_launchctl_submit_raises(self, tmp_path):
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
script = tmp_path / "persistent.sh"
|
|
script.write_text(
|
|
"#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
|
|
)
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("clean prompt", str(script))
|
|
|
|
@pytest.mark.parametrize("line", [
|
|
# #62891: neutral labels defeat any label-anchored regex, so cron
|
|
# scripts get the same label-independent submit/bootstrap block.
|
|
"launchctl submit -l com.foo -- /path/gateway",
|
|
"launchctl bootstrap gui/501 /tmp/com.foo.plist",
|
|
])
|
|
def test_script_with_neutral_label_submit_or_bootstrap_raises(
|
|
self, tmp_path, line
|
|
):
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
script = tmp_path / "persistent.sh"
|
|
script.write_text(f"#!/bin/bash\n{line}\n", encoding="utf-8")
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("clean prompt", str(script))
|
|
|
|
def test_split_across_prompt_and_script_still_blocks(self, tmp_path):
|
|
"""Concatenated scan prevents splitting the command between prompt and
|
|
script to slip through."""
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
script = tmp_path / "ops.sh"
|
|
script.write_text("hermes gateway stop\n", encoding="utf-8")
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("daily ops job", str(script))
|
|
|
|
def test_binary_script_does_not_silently_bypass(self, tmp_path):
|
|
"""Non-UTF-8 bytes used to be swallowed by UnicodeDecodeError; now we
|
|
decode with errors='replace' so the scan always sees the command."""
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
script = tmp_path / "weird.bin"
|
|
script.write_bytes(b"\xfehermes gateway restart\xff")
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("", str(script))
|
|
|
|
|
|
def test_relative_script_resolved_under_scripts_dir(self, tmp_path, monkeypatch):
|
|
"""A bare/relative script name resolves under HERMES_HOME/scripts (the
|
|
same place the scheduler runs it from) — otherwise the guard would read
|
|
a nonexistent relative path and scan prompt-only content."""
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
scripts_dir = tmp_path / ".hermes" / "scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "restart.sh").write_text(
|
|
"launchctl kickstart -k gui/501/ai.hermes.gateway\n"
|
|
)
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("daily", "restart.sh")
|
|
|
|
def test_python_script_with_pathlib_division_not_blocked(self, tmp_path):
|
|
"""#77131: a .py cron script using pathlib division (Path.home() /
|
|
".hermes") must NOT be blocked.
|
|
|
|
Before the fix, the shell-script reference walk tokenized Python
|
|
sources and treated pathlib's bare "/" operator as an executable
|
|
path resolving to the filesystem root, which fails the
|
|
regular-file check and hard-blocks every innocent .py script.
|
|
Python is executed by the interpreter, never through a POSIX shell,
|
|
so the walk is skipped for .py and only the direct command regex
|
|
runs.
|
|
"""
|
|
from cron.lifecycle_guard import check_gateway_lifecycle
|
|
script = tmp_path / "digest.py"
|
|
script.write_text(
|
|
"from pathlib import Path\n"
|
|
'ENV = Path.home() / ".hermes" / ".env"\n'
|
|
'print("digest ok")\n'
|
|
)
|
|
check_gateway_lifecycle("clean prompt", str(script))
|
|
|
|
def test_python_script_with_literal_lifecycle_command_still_blocked(
|
|
self, tmp_path
|
|
):
|
|
"""#77131: skipping the shell walk for .py must NOT weaken the guard —
|
|
a literal lifecycle command embedded in a .py script is still caught
|
|
by the direct regex scan."""
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
script = tmp_path / "evil.py"
|
|
script.write_text('import os\nos.system("hermes gateway restart")\n', encoding="utf-8")
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("clean prompt", str(script))
|
|
|
|
def test_absolute_path_binary_does_not_crash_guard(self):
|
|
"""#76762: a terminal command invoking a binary by absolute path
|
|
(e.g. /usr/bin/python3) must not crash the guard with
|
|
ValueError: embedded null byte.
|
|
|
|
Before the fix, the walk read the binary's bytes, decoded them as
|
|
text, and re-tokenized machine code containing NUL bytes; the
|
|
recursion then called Path.resolve() on a path with an embedded NUL
|
|
and only OSError was caught. Binaries are now skipped as
|
|
"nothing to scan" and ValueError is tolerated at resolve time.
|
|
"""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
result = contains_gateway_lifecycle_command_or_referenced_script(
|
|
'/usr/bin/python3 -c "print(1)"'
|
|
)
|
|
assert result is False
|
|
|
|
def test_nul_byte_in_path_token_does_not_crash_guard(self):
|
|
"""Residual #76762 class: when a NUL byte survives into the *path
|
|
token itself* (tokenized binary-adjacent command text), ``os.open``
|
|
raises ValueError — not OSError — inside
|
|
``_read_referenced_script``. The guard must treat it as "nothing to
|
|
scan", never crash.
|
|
"""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
result = contains_gateway_lifecycle_command_or_referenced_script(
|
|
"bash ./run\x00me.sh", cwd="/tmp"
|
|
)
|
|
assert result is False
|
|
|
|
def test_read_referenced_script_tolerates_nul_in_path(self):
|
|
"""#77703: _read_referenced_script opens by path. A path with an
|
|
embedded NUL byte (a binary's bytes mis-tokenized into a bogus path by
|
|
the recursion) makes os.open raise ValueError — not OSError — which used
|
|
to escape the OSError-only guard and crash the whole terminal tool. It
|
|
is now caught and reported as nothing-to-scan."""
|
|
from pathlib import Path
|
|
|
|
from cron.lifecycle_guard import _read_referenced_script
|
|
|
|
text, unsafe = _read_referenced_script(Path("/tmp/hermes\x00binary"))
|
|
assert text is None
|
|
assert unsafe is False
|
|
|
|
def test_remote_read_fallback_binary_does_not_crash_guard(self):
|
|
"""#77703: in the gateway the referenced-script walk carries a
|
|
``read_remote_script`` fallback (SSH/Modal/Daytona backends read the
|
|
script over the wire). When the referenced path is an ELF binary, that
|
|
fallback returned the binary's decoded bytes; the scanner then
|
|
tokenized machine code into bogus NUL-bearing paths and crashed with
|
|
``ValueError: embedded null byte`` (the tool errored out, the command
|
|
never ran). The guard must tolerate binary content from the fallback
|
|
and return False without raising."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
# Simulates the pre-fix _read_script_in_env handing back an ELF's
|
|
# decoded bytes (NUL preserved through errors="replace"). The newline
|
|
# puts a NUL-bearing absolute path in command position, exactly how the
|
|
# recursion re-tokenized machine code into a bogus script reference.
|
|
binary_blob = "\x7fELF\x01\x01\n/opt/bin/tool\x00\x01 --run\n"
|
|
|
|
def _remote_read(_path: str):
|
|
return binary_blob
|
|
|
|
result = contains_gateway_lifecycle_command_or_referenced_script(
|
|
"/home/zedi/venv/bin/python --version",
|
|
read_remote_script=_remote_read,
|
|
)
|
|
assert result is False
|
|
|
|
def test_shell_script_reference_walk_still_works(self, tmp_path):
|
|
"""The referenced-script walk still applies to real shell scripts:
|
|
a .sh script that itself invokes a lifecycle command is caught."""
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
|
script = tmp_path / "wrapper.sh"
|
|
script.write_text("#!/bin/bash\n./deploy.sh\n", encoding="utf-8")
|
|
(tmp_path / "deploy.sh").write_text("#!/bin/bash\nhermes gateway stop\n", encoding="utf-8")
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("daily ops", str(script))
|
|
|
|
def test_cloud_backed_symlink_fails_closed_without_opening_target(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""A FileProvider placeholder must not block terminal preflight.
|
|
|
|
``O_NONBLOCK`` has no effect on regular files. On macOS, opening an
|
|
iCloud placeholder can therefore wait indefinitely for hydration,
|
|
before the terminal command's own timeout has even started. Detect
|
|
the resolved FileProvider path from local metadata and fail closed
|
|
without opening it.
|
|
"""
|
|
import cron.lifecycle_guard as lifecycle_guard
|
|
|
|
cloud_dir = (
|
|
tmp_path
|
|
/ "Library"
|
|
/ "Mobile Documents"
|
|
/ "com~apple~CloudDocs"
|
|
/ "scripts"
|
|
)
|
|
cloud_dir.mkdir(parents=True)
|
|
target = cloud_dir / "helper"
|
|
target.write_text("#!/bin/sh\necho safe\n", encoding="utf-8")
|
|
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
launcher = bin_dir / "helper"
|
|
launcher.symlink_to(target)
|
|
|
|
real_open = lifecycle_guard.os.open
|
|
|
|
def reject_cloud_open(path, flags, *args, **kwargs):
|
|
if str(path) == str(launcher):
|
|
pytest.fail("lifecycle guard opened a cloud-backed symlink")
|
|
return real_open(path, flags, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(lifecycle_guard.os, "open", reject_cloud_open)
|
|
|
|
assert lifecycle_guard.contains_gateway_lifecycle_command_or_referenced_script(
|
|
str(launcher)
|
|
) is True
|
|
|
|
def test_third_party_cloudstorage_path_fails_closed_without_opening(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""~/Library/CloudStorage (Dropbox/OneDrive/Google Drive) is the same
|
|
FileProvider hazard as iCloud's Mobile Documents: an evicted
|
|
placeholder's open() can hang preflight. The guard must fail closed
|
|
on the lexical path without opening the file."""
|
|
import cron.lifecycle_guard as lifecycle_guard
|
|
|
|
cloud_dir = (
|
|
tmp_path
|
|
/ "Library"
|
|
/ "CloudStorage"
|
|
/ "Dropbox-Personal"
|
|
/ "scripts"
|
|
)
|
|
cloud_dir.mkdir(parents=True)
|
|
script = cloud_dir / "helper.sh"
|
|
script.write_text("#!/bin/sh\necho safe\n", encoding="utf-8")
|
|
|
|
real_open = lifecycle_guard.os.open
|
|
|
|
def reject_cloud_open(path, flags, *args, **kwargs):
|
|
if str(path) == str(script):
|
|
pytest.fail("lifecycle guard opened a CloudStorage path")
|
|
return real_open(path, flags, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(lifecycle_guard.os, "open", reject_cloud_open)
|
|
|
|
assert lifecycle_guard.contains_gateway_lifecycle_command_or_referenced_script(
|
|
str(script)
|
|
) is True
|
|
|
|
def test_read_referenced_script_choke_point_refuses_cloud_paths(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""The cloud refusal lives in _read_referenced_script itself so EVERY
|
|
caller (terminal walk AND cron-script scan) is covered — not just the
|
|
walk-level short-circuit in _contains_unsafe_gateway_action."""
|
|
import cron.lifecycle_guard as lifecycle_guard
|
|
|
|
cloud_dir = tmp_path / "Library" / "CloudStorage" / "OneDrive" / "s"
|
|
cloud_dir.mkdir(parents=True)
|
|
script = cloud_dir / "job.sh"
|
|
script.write_text("#!/bin/sh\necho safe\n", encoding="utf-8")
|
|
|
|
def forbid_open(path, flags, *args, **kwargs): # pragma: no cover
|
|
pytest.fail("choke point opened a cloud placeholder path")
|
|
|
|
monkeypatch.setattr(lifecycle_guard.os, "open", forbid_open)
|
|
|
|
text, unsafe = lifecycle_guard._read_referenced_script(script)
|
|
assert text is None
|
|
assert unsafe is True
|
|
|
|
def test_cron_script_scan_blocks_cloud_script_without_opening(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""The cron-script scan path (_read_script_for_scanning via
|
|
check_gateway_lifecycle) must also refuse a cloud-resident script
|
|
without opening it, and the surfaced reason must attribute the
|
|
refusal to the cloud-synced path — not to a lifecycle command."""
|
|
import cron.lifecycle_guard as lifecycle_guard
|
|
from cron.lifecycle_guard import (
|
|
GatewayLifecycleBlocked,
|
|
check_gateway_lifecycle,
|
|
)
|
|
|
|
cloud_dir = (
|
|
tmp_path
|
|
/ "Library"
|
|
/ "Mobile Documents"
|
|
/ "com~apple~CloudDocs"
|
|
/ "scripts"
|
|
)
|
|
cloud_dir.mkdir(parents=True)
|
|
script = cloud_dir / "nightly.sh"
|
|
script.write_text("#!/bin/sh\necho safe\n", encoding="utf-8")
|
|
|
|
real_open = lifecycle_guard.os.open
|
|
|
|
def reject_cloud_open(path, flags, *args, **kwargs):
|
|
if str(path) == str(script):
|
|
pytest.fail("cron-script scan opened a cloud-resident script")
|
|
return real_open(path, flags, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(lifecycle_guard.os, "open", reject_cloud_open)
|
|
|
|
with pytest.raises(GatewayLifecycleBlocked) as excinfo:
|
|
check_gateway_lifecycle("nightly job", str(script))
|
|
message = str(excinfo.value)
|
|
assert "cloud-synced" in message
|
|
assert "lifecycle command" not in message
|
|
|
|
# -- Whole-class regression tests (tilllt's T1-T4 on PR #79454) --------
|
|
|
|
def test_tilde_nul_candidate_does_not_crash_terminal_walk(self):
|
|
"""T1: ``Path('~user\\x00...').expanduser()`` raises ValueError one
|
|
frame *before* ``os.open`` — the per-syscall guards never see it. The
|
|
ingestion-boundary sanitizer must reject the candidate instead."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
result = contains_gateway_lifecycle_command_or_referenced_script(
|
|
"'~jenkins\x00broken/payload.sh' arg", cwd="/tmp"
|
|
)
|
|
assert result is False
|
|
|
|
def test_tilde_nul_candidate_does_not_crash_cron_script_resolution(self):
|
|
"""T2: the same ``expanduser`` crash via the cron ``script`` path
|
|
(``_resolve_script_path`` / ``check_gateway_lifecycle``)."""
|
|
from cron.lifecycle_guard import check_gateway_lifecycle
|
|
|
|
# Must neither raise ValueError nor block: an unresolvable script
|
|
# value has nothing to scan, and scheduler path validation reports
|
|
# the bad path separately.
|
|
check_gateway_lifecycle("daily ops", "~jenkins\x00broken/payload.sh")
|
|
|
|
def test_binary_from_remote_callback_never_false_positives(self):
|
|
"""T3: a ``read_remote_script`` callback returning NUL-bearing binary
|
|
text that *happens to contain* a lifecycle-looking fragment must be
|
|
skipped as binary at the recursion boundary — not matched and
|
|
blocked. Hardening one callback (#79454) left every other current or
|
|
future callback exposed; the boundary sanitizer covers them all."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
|
|
def _remote_read(_path: str):
|
|
return "MZ\x00\x00\x90\x00 hermes gateway restart \x00\x00junk"
|
|
|
|
result = contains_gateway_lifecycle_command_or_referenced_script(
|
|
"bash /nonexistent/dir/helper.sh",
|
|
cwd="/tmp",
|
|
read_remote_script=_remote_read,
|
|
)
|
|
assert result is False
|
|
|
|
def test_oversized_remote_callback_text_fails_closed(self):
|
|
"""T4: >1 MiB of NUL-free text from a remote callback must follow the
|
|
local-read contract (oversized regular file → fail closed, #76762)
|
|
instead of being scanned unbounded — the 179 MiB case from #77729."""
|
|
from cron.lifecycle_guard import (
|
|
_MAX_REFERENCED_SCRIPT_BYTES,
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
|
|
big = "x" * (_MAX_REFERENCED_SCRIPT_BYTES + 1)
|
|
|
|
result = contains_gateway_lifecycle_command_or_referenced_script(
|
|
"bash /nonexistent/dir/big_helper.sh",
|
|
cwd="/tmp",
|
|
read_remote_script=lambda _path: big,
|
|
)
|
|
assert result is True
|
|
|
|
def test_guard_is_total_against_adversarial_inputs(self, monkeypatch):
|
|
"""The public guard is a total function: no input may raise. Covers
|
|
the residual class beyond the four named sites — including
|
|
``expanduser`` RuntimeError when HOME is unset under launchd."""
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
|
|
monkeypatch.delenv("HOME", raising=False)
|
|
adversarial = [
|
|
"'~\x00' run",
|
|
"bash '~user\x00/x.sh'",
|
|
"source /tmp/e\x00vil.sh",
|
|
"sh ~/scripts/anything.sh", # HOME unset → RuntimeError pre-fix
|
|
". '\x00\x00\x00'",
|
|
"bash " + "A" * 5000 + ".sh", # over-long path → OSError
|
|
]
|
|
for command in adversarial:
|
|
# Must return a bool, never raise.
|
|
verdict = contains_gateway_lifecycle_command_or_referenced_script(
|
|
command, cwd="/tmp"
|
|
)
|
|
assert verdict is False
|
|
|
|
def test_walk_crash_falls_back_to_direct_scan_verdict(self, monkeypatch):
|
|
"""If the best-effort walk itself crashes, the guard logs and falls
|
|
back to the direct-scan verdict instead of propagating — a guard
|
|
crash breaks every terminal command until gateway restart (#77780),
|
|
strictly worse than either verdict."""
|
|
import cron.lifecycle_guard as lg
|
|
|
|
def _boom(*args, **kwargs):
|
|
raise RuntimeError("sibling site nobody found yet")
|
|
|
|
monkeypatch.setattr(lg, "_contains_unsafe_gateway_action", _boom)
|
|
# Direct scan still blocks a literal lifecycle command...
|
|
assert lg.contains_gateway_lifecycle_command_or_referenced_script(
|
|
"hermes gateway restart"
|
|
) is True
|
|
# ...and a benign command fails open instead of crashing.
|
|
assert lg.contains_gateway_lifecycle_command_or_referenced_script(
|
|
"echo hello"
|
|
) is False
|
|
|
|
def test_cron_guard_total_when_home_unresolvable(self, monkeypatch):
|
|
"""`get_hermes_home()` falls back to Path.home(), which raises
|
|
RuntimeError when neither HERMES_HOME nor HOME resolves
|
|
(arbitrary-UID containers, launchd). The cron entry point must
|
|
treat a relative script value as unresolvable — nothing to scan —
|
|
not crash."""
|
|
from pathlib import Path
|
|
|
|
from cron.lifecycle_guard import check_gateway_lifecycle
|
|
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
monkeypatch.delenv("HOME", raising=False)
|
|
monkeypatch.setattr(
|
|
Path,
|
|
"home",
|
|
classmethod(
|
|
lambda cls: (_ for _ in ()).throw(
|
|
RuntimeError("Could not determine home directory")
|
|
)
|
|
),
|
|
)
|
|
# Must not raise; relative script cannot resolve without a home.
|
|
check_gateway_lifecycle("daily ops", "relative-script.sh")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defense 2 (chokepoint): cron.jobs.create_job blocks the AGENT model-tool path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDotSourceIsScannedLikeSource:
|
|
"""`.` and `source` are the same POSIX builtin and must scan alike.
|
|
|
|
`Path(".").name` is "" — pathlib has no name component for a pure-path
|
|
token — so keying the sourced-script branch on it left the `.` spelling
|
|
unreachable. `source ./helper.sh` was scanned while `. ./helper.sh`
|
|
walked straight past both the cron guard and the in-gateway terminal
|
|
guard, carrying whatever the sourced script contained.
|
|
"""
|
|
|
|
def _scan(self, command, cwd):
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
|
|
return contains_gateway_lifecycle_command_or_referenced_script(
|
|
command, cwd=cwd
|
|
)
|
|
|
|
@pytest.fixture
|
|
def helper(self, tmp_path):
|
|
script = tmp_path / "helper.sh"
|
|
script.write_text("#!/bin/sh\nhermes gateway restart\n", encoding="utf-8")
|
|
return script
|
|
|
|
@pytest.mark.parametrize("form", [". {path}", "source {path}"])
|
|
def test_both_spellings_block_a_referenced_script(self, tmp_path, helper, form):
|
|
assert self._scan(form.format(path=helper), cwd=str(tmp_path)) is True
|
|
|
|
@pytest.mark.parametrize("form", [". ./helper.sh", "source ./helper.sh"])
|
|
def test_both_spellings_block_a_relative_reference(self, tmp_path, helper, form):
|
|
assert self._scan(form, cwd=str(tmp_path)) is True
|
|
|
|
def test_env_assignment_prefix_does_not_hide_dot_source(self, tmp_path, helper):
|
|
assert self._scan(f"FOO=1 . {helper}", cwd=str(tmp_path)) is True
|
|
|
|
def test_dot_source_nested_in_shell_c_is_blocked(self, tmp_path, helper):
|
|
assert self._scan(f"sh -c '. {helper}'", cwd=str(tmp_path)) is True
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# `.` as a plain path argument is not a source and must stay allowed —
|
|
# this is the #77131 false-positive class the guard already carries
|
|
# scar tissue for.
|
|
"find . -name '*.py'",
|
|
"git add .",
|
|
"cd . && make",
|
|
"tar -czf out.tgz .",
|
|
"cp -r . /tmp/backup",
|
|
])
|
|
def test_dot_as_an_argument_is_not_treated_as_a_source(self, tmp_path, command):
|
|
assert self._scan(command, cwd=str(tmp_path)) is False
|
|
|
|
def test_sourcing_a_clean_script_is_allowed(self, tmp_path):
|
|
clean = tmp_path / "ok.sh"
|
|
clean.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
|
assert self._scan(f". {clean}", cwd=str(tmp_path)) is False
|
|
|
|
|
|
class TestTransparentWrapperPrefixes:
|
|
"""`sudo`/`env`/`nohup`/... exec their argument tail, so the command that
|
|
actually runs sits further right. Reading only the first token made the
|
|
referenced-script walk, the `sh -c` payload walk and the label-independent
|
|
`launchctl submit` block (#62891) all miss a wrapped invocation:
|
|
`sudo bash ~/restart.sh` sailed past a guard that stops
|
|
`bash ~/restart.sh`."""
|
|
|
|
def _scan(self, command, cwd=None):
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
|
|
return contains_gateway_lifecycle_command_or_referenced_script(
|
|
command, cwd=cwd
|
|
)
|
|
|
|
@pytest.fixture
|
|
def helper(self, tmp_path):
|
|
script = tmp_path / "helper.sh"
|
|
script.write_text("#!/bin/sh\nhermes gateway restart\n", encoding="utf-8")
|
|
return script
|
|
|
|
@pytest.mark.parametrize("prefix", [
|
|
"sudo", "doas", "env", "nohup", "setsid", "nice", "eatmydata",
|
|
"exec", "command", "stdbuf -o0", "nice -n 5", "sudo -u deploy",
|
|
"env FOO=bar", "timeout 60", "timeout -k 5 60", "sudo --",
|
|
])
|
|
def test_wrapped_script_reference_is_scanned(self, tmp_path, helper, prefix):
|
|
assert self._scan(f"{prefix} bash {helper}", cwd=str(tmp_path)) is True
|
|
|
|
@pytest.mark.parametrize("prefix", ["sudo", "env", "nohup", "timeout 60"])
|
|
def test_wrapped_dot_source_is_scanned(self, tmp_path, helper, prefix):
|
|
assert self._scan(f"{prefix} . {helper}", cwd=str(tmp_path)) is True
|
|
|
|
@pytest.mark.parametrize("prefix", ["sudo", "env", "nohup"])
|
|
def test_wrapped_shell_c_payload_is_scanned(self, tmp_path, helper, prefix):
|
|
assert self._scan(
|
|
f"{prefix} sh -c 'bash {helper}'", cwd=str(tmp_path)
|
|
) is True
|
|
|
|
@pytest.mark.parametrize("prefix", ["sudo", "env", "nohup", "setsid"])
|
|
def test_wrapped_launchctl_submit_is_blocked(self, prefix):
|
|
from cron.lifecycle_guard import contains_launchctl_submit_command
|
|
|
|
assert contains_launchctl_submit_command(
|
|
f"{prefix} launchctl submit -l com.example.helper -- /usr/bin/true"
|
|
) is True
|
|
|
|
@pytest.mark.parametrize("prefix", [
|
|
# Privilege and namespace wrappers, including the option forms whose
|
|
# operand is a VALUE rather than the command.
|
|
"pkexec", "pkexec --user root",
|
|
"runuser -u root --", "setpriv --reuid=0 --",
|
|
"systemd-run --scope", "systemd-run -p X=1",
|
|
"nsenter --target 1 --mount", "nsenter -t 1 -m",
|
|
"unshare -r", "sudo pkexec",
|
|
])
|
|
def test_privilege_and_namespace_wrappers_are_scanned(
|
|
self, tmp_path, helper, prefix
|
|
):
|
|
assert self._scan(f"{prefix} bash {helper}", cwd=str(tmp_path)) is True
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# An option carrying a COMMAND STRING is shell source, not an opaque
|
|
# value: skipping it would hide whatever it runs.
|
|
"env -S 'bash {path}'",
|
|
"env --split-string='bash {path}'",
|
|
"su -c 'bash {path}'",
|
|
"su root -c 'bash {path}'",
|
|
"runuser -u root -c 'bash {path}'",
|
|
])
|
|
def test_command_string_options_are_rescanned(self, tmp_path, helper, command):
|
|
assert self._scan(command.format(path=helper), cwd=str(tmp_path)) is True
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# The same wrappers around ordinary work must not start blocking.
|
|
"pkexec systemctl status nginx",
|
|
"su -c 'ls -la'",
|
|
"unshare -r whoami",
|
|
"systemd-run --scope make -j4",
|
|
"nsenter -t 1 -m ps aux",
|
|
"setpriv --reuid=0 -- id",
|
|
"runuser -u nobody -- whoami",
|
|
"env -S 'echo hi'",
|
|
])
|
|
def test_privilege_wrappers_around_benign_work_are_allowed(
|
|
self, tmp_path, command
|
|
):
|
|
assert self._scan(command, cwd=str(tmp_path)) is False
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# Wrappers around ordinary work must stay allowed — peeling must not
|
|
# invent a script reference where there is none.
|
|
"sudo apt-get update",
|
|
"env FOO=bar python3 script.py",
|
|
"timeout 60 curl https://example.com",
|
|
"nohup python3 -m http.server &",
|
|
"sudo -u postgres psql -c 'SELECT 1'",
|
|
"nice -n 10 make -j4",
|
|
"env",
|
|
"sudo",
|
|
])
|
|
def test_wrapped_benign_commands_are_allowed(self, tmp_path, command):
|
|
assert self._scan(command, cwd=str(tmp_path)) is False
|
|
|
|
@pytest.mark.parametrize("name", ["timeout", "env", "nice", "command"])
|
|
def test_local_script_named_like_a_wrapper_is_still_scanned(
|
|
self, tmp_path, name
|
|
):
|
|
"""`./timeout` is a script in the cwd, not the coreutils wrapper.
|
|
Peeling is additive precisely so it cannot swallow the reference the
|
|
un-peeled read finds."""
|
|
script = tmp_path / name
|
|
script.write_text("#!/bin/sh\nhermes gateway restart\n", encoding="utf-8")
|
|
assert self._scan(f"./{name}", cwd=str(tmp_path)) is True
|
|
assert self._scan(str(script), cwd=str(tmp_path)) is True
|
|
|
|
def test_wrapped_clean_script_is_allowed(self, tmp_path):
|
|
clean = tmp_path / "ok.sh"
|
|
clean.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
|
assert self._scan(f"sudo bash {clean}", cwd=str(tmp_path)) is False
|
|
|
|
|
|
class TestRelativePathDoesNotDisableDataExemption:
|
|
"""A leading dot disables the data-sink exemption because sqlite3 spells
|
|
its escapes as dot-commands (`.shell`). `.`, `./x` and `../x` are plain
|
|
path operands, so `grep -r <pattern> .` — the most ordinary recursive
|
|
search there is — was blocked outright when the pattern happened to be a
|
|
lifecycle string."""
|
|
|
|
def _scan(self, command):
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
|
|
return contains_gateway_lifecycle_command_or_referenced_script(command)
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
"grep -r 'systemctl restart hermes-gateway' .",
|
|
"grep -rn 'hermes gateway restart' ./logs",
|
|
"rg 'hermes gateway restart' ../archive",
|
|
"grep -c 'systemctl stop hermes-gateway' ./var/log/syslog",
|
|
"sqlite3 ./stats.db \"SELECT restart_reason FROM hermes_gateway_restarts\"",
|
|
])
|
|
def test_relative_path_operands_keep_the_exemption(self, command):
|
|
assert self._scan(command) is False
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# Narrowing the dot test must not open an execution route: every
|
|
# escape hatch still fires with a relative-path operand present.
|
|
'sqlite3 ./db ".shell hermes gateway restart"',
|
|
'sqlite3 ./db ".system systemctl restart hermes-gateway"',
|
|
'psql ./x -c "\\! systemctl restart hermes-gateway"',
|
|
"grep -r 'hermes gateway restart' . | sh",
|
|
"grep -r 'hermes gateway restart' ./logs | bash",
|
|
"grep -r 'hermes gateway restart' . | sudo sh",
|
|
"grep -r 'x' . ; hermes gateway restart",
|
|
"grep -r 'x' . && systemctl restart hermes-gateway",
|
|
'grep -r "$(hermes gateway restart)" .',
|
|
"rg 'x' ./logs | xargs systemctl restart hermes-gateway",
|
|
])
|
|
def test_relative_path_does_not_open_an_execution_route(self, command):
|
|
assert self._scan(command) is True
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# Real dot-commands must still defeat the exemption.
|
|
'sqlite3 db ".shell hermes gateway restart"',
|
|
'sqlite3 db ".system systemctl restart hermes-gateway"',
|
|
'psql -c "\\! systemctl restart hermes-gateway"',
|
|
])
|
|
def test_dot_commands_still_block(self, command):
|
|
assert self._scan(command) is True
|
|
|
|
|
|
class TestCreateJobBlocksLifecycleCommands:
|
|
"""The regression the CLI-layer-only guard could not catch: the agent's
|
|
`cronjob` model tool calls cron.jobs.create_job directly, bypassing
|
|
hermes_cli.cron.cron_create. Enforcing at create_job covers both."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_cron_dir(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
|
|
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
|
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
|
|
|
|
def test_create_job_blocks_prompt_command(self):
|
|
from cron.jobs import create_job
|
|
from cron.lifecycle_guard import GatewayLifecycleBlocked
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
create_job(prompt="then run hermes gateway restart", schedule="30m")
|
|
|
|
def test_create_job_allows_benign_prompt(self):
|
|
from cron.jobs import create_job
|
|
job = create_job(prompt="summarize the API gateway logs and note restart events",
|
|
schedule="30m")
|
|
assert job["id"]
|
|
|
|
def test_cronjob_tool_surfaces_block_as_error(self, tmp_path, monkeypatch):
|
|
"""End-to-end through the model tool: the block comes back as
|
|
result['error'] with the #30719 hint, not an unhandled exception."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
(tmp_path / ".hermes").mkdir(parents=True)
|
|
from tools.cronjob_tools import cronjob
|
|
result = json.loads(cronjob(
|
|
action="create", schedule="0 9 * * *",
|
|
prompt="please run hermes gateway restart nightly",
|
|
))
|
|
assert result.get("success") is False
|
|
assert "#30719" in result.get("error", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defense 3: auto-resume restart-loop breaker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRestartLoopGuard:
|
|
"""gateway.restart_loop_guard trips after >= max_restarts
|
|
restart-interrupted boots inside window_seconds, breaking a
|
|
SIGTERM-respawn loop that defenses 1-2 don't cover."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_state(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
(tmp_path / ".hermes").mkdir(parents=True)
|
|
import gateway.restart_loop_guard as rlg
|
|
rlg.clear()
|
|
|
|
|
|
|
|
|
|
def test_is_tripped_reads_without_recording(self):
|
|
import gateway.restart_loop_guard as rlg
|
|
rlg.record_restart_interrupted_boot(60, now=1000.0)
|
|
rlg.record_restart_interrupted_boot(60, now=1001.0)
|
|
assert rlg.is_restart_loop_tripped(3, 60, now=1002.0) is False
|
|
rlg.record_restart_interrupted_boot(60, now=1002.0)
|
|
assert rlg.is_restart_loop_tripped(3, 60, now=1003.0) is True
|
|
|
|
def test_clear_resets(self):
|
|
import gateway.restart_loop_guard as rlg
|
|
rlg.check_and_record(3, 60, now=1000.0)
|
|
rlg.check_and_record(3, 60, now=1001.0)
|
|
rlg.clear()
|
|
assert rlg.check_and_record(3, 60, now=1002.0) is False
|
|
|
|
def test_trips_on_slow_crash_cycle_wider_than_window(self):
|
|
"""#81642: a ~150s crash cycle is wider than the 60s window, so the
|
|
old absolute-window prune dropped the previous boot on every boot and
|
|
the counter never left 1. Chaining on the inter-boot gap sees it."""
|
|
import gateway.restart_loop_guard as rlg
|
|
assert rlg.check_and_record(3, 60, now=1000.0) is False
|
|
assert rlg.check_and_record(3, 60, now=1150.0) is False
|
|
assert rlg.check_and_record(3, 60, now=1300.0) is True
|
|
|
|
def test_slow_cycle_chain_is_persisted_not_truncated(self):
|
|
"""The state file must keep the whole chain — the reported symptom was
|
|
a restart_loop.json holding a single timestamp after 15 crashes."""
|
|
import gateway.restart_loop_guard as rlg
|
|
rlg.record_restart_interrupted_boot(60, now=1000.0)
|
|
rlg.record_restart_interrupted_boot(60, now=1150.0)
|
|
boots = rlg.record_restart_interrupted_boot(60, now=1300.0)
|
|
assert boots == [1000.0, 1150.0, 1300.0]
|
|
|
|
def test_quiet_period_breaks_the_chain(self):
|
|
"""A boot after real quiet starts a fresh chain, so occasional
|
|
operator restarts never accumulate into a trip."""
|
|
import gateway.restart_loop_guard as rlg
|
|
rlg.check_and_record(3, 60, now=1000.0)
|
|
rlg.check_and_record(3, 60, now=1150.0)
|
|
# 1h later: unrelated restart, chain reset to a single boot.
|
|
assert rlg.check_and_record(3, 60, now=4800.0) is False
|
|
assert rlg.is_restart_loop_tripped(3, 60, now=4801.0) is False
|
|
|
|
def test_fast_respawn_loop_still_trips(self):
|
|
"""#30719 regression: the original ~10s loop must keep tripping."""
|
|
import gateway.restart_loop_guard as rlg
|
|
assert rlg.check_and_record(3, 60, now=1000.0) is False
|
|
assert rlg.check_and_record(3, 60, now=1010.0) is False
|
|
assert rlg.check_and_record(3, 60, now=1020.0) is True
|
|
|
|
def test_max_gap_seconds_is_configurable(self):
|
|
"""An operator can narrow the chain gap back down; a cycle slower than
|
|
the configured gap then stops chaining."""
|
|
import gateway.restart_loop_guard as rlg
|
|
assert rlg.check_and_record(3, 60, now=1000.0, max_gap_seconds=100) is False
|
|
assert rlg.check_and_record(3, 60, now=1150.0, max_gap_seconds=100) is False
|
|
assert rlg.check_and_record(3, 60, now=1300.0, max_gap_seconds=100) is False
|
|
|
|
def test_window_seconds_floors_the_gap(self):
|
|
"""A window wider than the gap default still governs, so raising
|
|
window_seconds never makes the breaker less sensitive."""
|
|
import gateway.restart_loop_guard as rlg
|
|
assert rlg.check_and_record(3, 900, now=1000.0, max_gap_seconds=100) is False
|
|
assert rlg.check_and_record(3, 900, now=1400.0, max_gap_seconds=100) is False
|
|
assert rlg.check_and_record(3, 900, now=1800.0, max_gap_seconds=100) is True
|
|
|
|
def test_disabled_breaker_never_trips(self):
|
|
import gateway.restart_loop_guard as rlg
|
|
for ts in (1000.0, 1150.0, 1300.0, 1450.0):
|
|
assert rlg.check_and_record(0, 60, now=ts) is False
|
|
assert rlg.is_restart_loop_tripped(0, 60, now=1451.0) is False
|
|
|
|
class TestTerminalToolGatewayLifecycleGuardRemote:
|
|
"""Remote-backend and two-session cwd regression coverage."""
|
|
|
|
def _patch_env(self, monkeypatch, fake_env, *, inside_gateway: bool):
|
|
import tools.terminal_tool as tt
|
|
from tools import process_registry
|
|
eid = "default"
|
|
monkeypatch.setattr(tt, "_active_environments", {eid: fake_env})
|
|
monkeypatch.setattr(tt, "_last_activity", {eid: 0.0})
|
|
monkeypatch.setattr(tt, "_task_env_overrides", {})
|
|
monkeypatch.setattr(tt, "_get_env_config", lambda: {"env_type": "local", "cwd": "/tmp", "timeout": 60, "lifetime_seconds": 3600})
|
|
monkeypatch.setattr(
|
|
process_registry, "_is_supervised_gateway_process",
|
|
lambda: inside_gateway,
|
|
)
|
|
|
|
def test_remote_backend_script_read_uses_env_execute(self, monkeypatch, tmp_path):
|
|
import tools.terminal_tool as tt
|
|
|
|
# Path only exists on the remote backend; locally it is absent, so the
|
|
# guard must fall back to a bounded env.execute('head -c ...') read.
|
|
script = "/remote/workspace/remote.sh"
|
|
calls = []
|
|
|
|
class _RemoteEnv:
|
|
env = {}
|
|
cwd = str(tmp_path)
|
|
def execute(self, command, **kwargs):
|
|
calls.append(command)
|
|
if "head -c" in command and "/remote/workspace/remote.sh" in command:
|
|
return {"output": "#!/bin/bash\nhermes gateway restart\n", "returncode": 0}
|
|
return {"output": "", "returncode": 0}
|
|
|
|
fake_env = _RemoteEnv()
|
|
fake_env.cwd = "/remote/workspace"
|
|
self._patch_env(monkeypatch, fake_env, inside_gateway=True)
|
|
|
|
result = json.loads(tt.terminal_tool(command=f"/bin/bash {script}"))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "referenced script" in result["error"]
|
|
assert any("head -c" in c for c in calls)
|
|
|
|
|
|
class TestCronCreateLifecycleBlockExtra:
|
|
"""Additional cron create lifecycle guard coverage."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_cron_dir(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
|
|
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
|
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
|
|
|
|
def test_cron_nested_wrapper_script_is_scanned(self, tmp_path, capsys, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
scripts_dir = tmp_path / ".hermes" / "scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "inner.sh").write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
|
(scripts_dir / "outer.sh").write_text("#!/bin/bash\n/bin/bash inner.sh\n", encoding="utf-8")
|
|
args = Namespace(
|
|
cron_command="create",
|
|
schedule="1h",
|
|
prompt=None,
|
|
name=None,
|
|
deliver=None,
|
|
repeat=None,
|
|
skill=None,
|
|
skills=None,
|
|
script="outer.sh",
|
|
workdir=None,
|
|
profile=None,
|
|
no_agent=True,
|
|
)
|
|
rc = cron_command(args)
|
|
assert rc == 1
|
|
out = capsys.readouterr().out
|
|
assert "Blocked" in out
|
|
|
|
class TestLifecycleGuardDataArgumentExemption:
|
|
"""Lifecycle words inside DATA arguments (SQL text, grep patterns) must
|
|
not block; the same words in command position must. Reproduces the two
|
|
live false positives (Aug 2026): a sqlite3 SELECT over restart-history
|
|
text and a grep for the lifecycle string in syslog."""
|
|
|
|
def _scan(self, command, **kwargs):
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
return contains_gateway_lifecycle_command_or_referenced_script(
|
|
command, **kwargs
|
|
)
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# Exact live false-positive shapes: SQL string literals carrying the
|
|
# full lifecycle command as text.
|
|
'sqlite3 db "SELECT msg FROM log WHERE msg LIKE '
|
|
"'%systemctl restart hermes-gateway%'\"",
|
|
'psql -c "SELECT * FROM events WHERE cmd = '
|
|
"'systemctl stop hermes-gateway'\"",
|
|
# grep/rg pattern arguments hunting for the lifecycle string.
|
|
"grep -c 'systemctl restart hermes-gateway' /var/log/syslog",
|
|
"rg 'hermes gateway restart' /home/user/.hermes/logs/",
|
|
"journalctl -u hermes-gateway --grep 'systemctl restart hermes-gateway'",
|
|
# SQL with stop/restart column/value words but no command shape.
|
|
'sqlite3 stats.db "SELECT stop_time, restart_reason FROM '
|
|
'hermes_gateway_restarts"',
|
|
"psql -c \"SELECT count(*) FROM events WHERE action IN "
|
|
"('stop','restart') AND service LIKE '%gateway%'\"",
|
|
])
|
|
def test_data_argument_lifecycle_text_not_blocked(self, command):
|
|
assert self._scan(command) is False
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
# Execution smuggled through or around a data sink must still block.
|
|
'sqlite3 db ".shell hermes gateway restart"',
|
|
'psql -c "\\! systemctl restart hermes-gateway"',
|
|
"grep 'systemctl restart hermes-gateway' cmds.txt | sh",
|
|
"grep gateway f | xargs systemctl restart hermes-gateway",
|
|
'grep "$(systemctl restart hermes-gateway)" f',
|
|
"grep 'restart' log; systemctl restart hermes-gateway",
|
|
'sqlite3 db "SELECT 1"; hermes gateway stop',
|
|
# Plain lifecycle commands are unaffected by the exemption.
|
|
"hermes gateway restart",
|
|
"sudo systemctl stop hermes-gateway",
|
|
])
|
|
def test_command_position_lifecycle_still_blocked(self, command):
|
|
assert self._scan(command) is True
|
|
|
|
def test_python_script_branch_gets_the_same_exemption(self, tmp_path):
|
|
"""check_gateway_lifecycle's .py branch scans the combined
|
|
prompt+script text with the direct regex; a shell-shaped diagnostic
|
|
command in the PROMPT (the live false-positive shape) must not block
|
|
a job that runs a clean .py script. Note the exemption is
|
|
fail-closed: the same SQL buried in non-shell-shaped Python source
|
|
(e.g. inside a subprocess.run list literal) stays blocked because
|
|
the masker cannot prove it is data."""
|
|
from cron.lifecycle_guard import check_gateway_lifecycle
|
|
script = tmp_path / "report.py"
|
|
script.write_text("print('nightly report')\n", encoding="utf-8")
|
|
prompt = (
|
|
'sqlite3 db "SELECT msg FROM log '
|
|
"WHERE msg LIKE '%systemctl restart hermes-gateway%'\""
|
|
)
|
|
check_gateway_lifecycle(prompt, str(script))
|
|
|
|
|
|
class TestLifecycleGuardNeverRaises:
|
|
"""The guard must return a verdict for every input — binary referenced
|
|
paths, NUL bytes, non-UTF-8, /dev/* nodes, directories, missing files —
|
|
never crash (the live 'ValueError: embedded null byte' class)."""
|
|
|
|
def _scan(self, command, **kwargs):
|
|
from cron.lifecycle_guard import (
|
|
contains_gateway_lifecycle_command_or_referenced_script,
|
|
)
|
|
return contains_gateway_lifecycle_command_or_referenced_script(
|
|
command, **kwargs
|
|
)
|
|
|
|
def test_command_referencing_elf_binary_returns_false(self, tmp_path):
|
|
"""The exact live crash shape: a command referencing a compiled
|
|
executable path (e.g. a venv python) must scan as 'nothing', not
|
|
crash on the binary's decoded bytes."""
|
|
binary = tmp_path / "python3.11"
|
|
binary.write_bytes(b"\x7fELF\x02\x01\x01" + bytes(64) + b"\x90" * 256)
|
|
assert self._scan(f"{binary} -m json.tool /tmp/x.json") is False
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
"run /tmp/foo\x00bar/baz.sh",
|
|
"bash ./run\x00me.sh",
|
|
"bash /nonexistent/deeply/missing.sh",
|
|
"bash /" + "a" * 4096 + ".sh", # ENAMETOOLONG
|
|
])
|
|
def test_adversarial_paths_never_raise(self, command):
|
|
assert self._scan(command, cwd="/tmp") is False
|
|
|
|
def test_non_utf8_referenced_file_never_raises(self, tmp_path):
|
|
weird = tmp_path / "weird.sh"
|
|
weird.write_bytes(b"\xff\xfe\x00\x01 not really a script")
|
|
assert self._scan(f"bash {weird}") is False
|
|
|
|
def test_sourced_zshrc_docker_completions_dir_is_not_blocked(self, tmp_path):
|
|
"""#86753: Docker Desktop writes ``fpath=(~/.docker/completions …)``
|
|
into ``.zshrc``. Completions is a directory. The walk must treat
|
|
that as nothing-to-scan, not fail-closed, or ``source ~/.zshrc``
|
|
is blocked on every terminal command."""
|
|
completions = tmp_path / ".docker" / "completions"
|
|
completions.mkdir(parents=True)
|
|
zshrc = tmp_path / ".zshrc"
|
|
zshrc.write_text(
|
|
f"fpath=({completions} /usr/local/share/zsh/site-functions $fpath)\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert self._scan(f"source {zshrc}") is False
|
|
|
|
def test_fstat_directory_mode_is_not_unsafe(self, tmp_path, monkeypatch):
|
|
"""#86753 Unix contract: os.open(dir) succeeds, fstat is not S_ISREG.
|
|
|
|
Windows raises OSError on os.open(dir) and already returns
|
|
nothing-to-scan. Linux/macOS open the directory and used to
|
|
return unsafe=True, blocking sourced zshrcs that mention
|
|
``~/.docker/completions``.
|
|
"""
|
|
import os
|
|
import stat as statmod
|
|
|
|
from cron.lifecycle_guard import _read_referenced_script
|
|
|
|
probe = tmp_path / "probe"
|
|
probe.write_text("echo hi\n", encoding="utf-8")
|
|
orig = os.fstat
|
|
|
|
def _dir_fstat(fd):
|
|
orig(fd)
|
|
class _DirStat:
|
|
st_mode = statmod.S_IFDIR | 0o755
|
|
return _DirStat()
|
|
|
|
monkeypatch.setattr(os, "fstat", _dir_fstat)
|
|
text, unsafe = _read_referenced_script(probe)
|
|
assert text is None
|
|
assert unsafe is False
|
|
|
|
def test_directory_and_dev_null_fail_closed_not_crash(self, tmp_path):
|
|
# Directories are not scripts (#86753). Devices stay fail-closed
|
|
# where the OS actually exposes them (POSIX /dev/null).
|
|
# The important contract is: verdict, not exception.
|
|
assert self._scan(f"bash {tmp_path}") is False
|
|
if os.name != "nt":
|
|
assert self._scan("bash /dev/null") is True
|
|
|
|
def test_magic_prefix_binaries_skipped_without_full_read(self, tmp_path):
|
|
"""Executable magic (ELF/PE/Mach-O) short-circuits the read: the
|
|
guard must not treat compiled binaries as scripts at all."""
|
|
from cron.lifecycle_guard import _read_referenced_script
|
|
for name, magic in [
|
|
("elf", b"\x7fELF"),
|
|
("pe", b"MZ"),
|
|
("macho", b"\xcf\xfa\xed\xfe"),
|
|
("fat", b"\xca\xfe\xba\xbe"),
|
|
]:
|
|
path = tmp_path / name
|
|
# No NUL after the magic — proves the magic check itself fires.
|
|
path.write_bytes(magic + b"ABCDEF" * 10)
|
|
text, unsafe = _read_referenced_script(path)
|
|
assert text is None, name
|
|
assert unsafe is False, name
|
|
|
|
def test_check_gateway_lifecycle_adversarial_script_values(self, tmp_path):
|
|
"""check_gateway_lifecycle must never raise anything but the
|
|
documented GatewayLifecycleBlocked for junk script values."""
|
|
from cron.lifecycle_guard import (
|
|
GatewayLifecycleBlocked,
|
|
check_gateway_lifecycle,
|
|
)
|
|
binary = tmp_path / "prog"
|
|
binary.write_bytes(b"\x7fELF" + bytes(128))
|
|
for value in ("nul\x00byte.sh", str(binary), "/nonexistent/x.sh", str(tmp_path)):
|
|
check_gateway_lifecycle("clean prompt", value) # must not raise
|
|
if os.name != "nt":
|
|
with pytest.raises(GatewayLifecycleBlocked):
|
|
check_gateway_lifecycle("clean prompt", "/dev/null")
|