Files
hermes-agent/tests/cron/test_lifecycle_guard_budget.py
T
kshitijk4poor d99eed7d83 perf(cron): bound the lifecycle guard's whole-walk scan work
The referenced-script walk in cron/lifecycle_guard.py capped each file
(1 MiB) and the recursion depth (8) but not the walk: a command referencing
hundreds of scripts, or one enormous shlex token, held the GIL for minutes
on every gateway terminal call (#78398).

Add a per-walk _LifecycleScanBudget (bytes, lines, longest line, unique
paths, remote reads) charged BEFORE any text reaches shlex, and cap each
referenced read at the remaining byte budget so an oversized file is never
read whole. Exhaustion fails closed (the existing contract for one oversized
file) and is logged at WARNING so operators can tell it from a genuine
lifecycle block. Limits are sized so real wrapper graphs never hit them:
a 200-script benign graph is allowed and a restart hidden behind it is
still caught.

tools/terminal_tool.py gates its optional launchctl pre-scan (which also
tokenizes) on the same budget; the full guard still runs afterwards.

Redesigned from #83821 by @Riccardo-Vecchi, which introduced the budget
idea but blocked benign wide graphs (64-path cap) and bundled a suffix
classification change that is left out here.

Refs #78398
2026-09-03 03:33:24 +05:30

242 lines
8.9 KiB
Python

"""Whole-walk work budget for the gateway lifecycle guard (#78398).
The per-file byte cap and recursion depth bound one read, not the walk. These
tests pin the shared budget that bounds the whole referenced-script walk and
is charged *before* any text reaches ``shlex``.
Budget constants are monkeypatched to tiny values so the tests are fast and
deterministic; ``_LifecycleScanBudget`` reads them at construction time.
"""
from __future__ import annotations
import pytest
import cron.lifecycle_guard as lifecycle_guard
guard = lifecycle_guard.contains_gateway_lifecycle_command_or_referenced_script
def _explode(*_args, **_kwargs):
raise AssertionError("over-budget text reached shlex")
# --- root command (depth 0) -----------------------------------------------
def test_root_byte_limit_allows_exact_and_rejects_plus_one(monkeypatch):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 8)
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 8)
assert guard("x" * 8) is False
monkeypatch.setattr(lifecycle_guard.shlex, "shlex", _explode)
assert guard("x" * 9) is True
def test_root_line_limit_allows_exact_and_rejects_plus_one(monkeypatch):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINES", 2)
assert guard("one\ntwo") is False
assert guard("one\ntwo\nthree") is True
def test_single_giant_line_rejected_before_shlex(monkeypatch):
"""One enormous token is the quadratic shlex case."""
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 8)
monkeypatch.setattr(lifecycle_guard.shlex, "shlex", _explode)
assert guard("xxxxxxxxx\necho ok") is True
def test_root_budget_counts_utf8_bytes(monkeypatch):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 4)
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 4)
assert guard("éé") is False
assert guard("ééé") is True
def test_exhaustion_is_logged_at_warning(monkeypatch, caplog):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 4)
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 4)
with caplog.at_level("WARNING", logger=lifecycle_guard.logger.name):
assert guard("echo hello") is True
assert "budget exhausted" in caplog.text
def test_lifecycle_scan_root_within_budget_is_not_a_verdict(monkeypatch):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 8)
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 8)
assert lifecycle_guard.lifecycle_scan_root_within_budget("x" * 8) is True
assert lifecycle_guard.lifecycle_scan_root_within_budget("x" * 9) is False
# --- referenced-script walk ------------------------------------------------
def test_unique_path_budget_bounds_reads_and_fails_closed(monkeypatch, tmp_path):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_PATHS", 2)
for i in range(3):
(tmp_path / f"s{i}.sh").write_text("echo ok\n", encoding="utf-8")
two = " && ".join(f"bash {tmp_path}/s{i}.sh" for i in range(2))
three = " && ".join(f"bash {tmp_path}/s{i}.sh" for i in range(3))
assert guard(two) is False
assert guard(three) is True
def test_repeated_path_does_not_spend_unique_path_budget(monkeypatch, tmp_path):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_PATHS", 1)
script = tmp_path / "s.sh"
script.write_text("echo ok\n", encoding="utf-8")
assert guard(f"bash {script} && bash {script} && sh {script}") is False
def test_remote_read_budget_charged_before_remote_read(monkeypatch):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_REMOTE_READS", 1)
reads: list[str] = []
def remote(path: str):
reads.append(path)
return "echo ok\n"
assert (
guard(
"bash /remote/a.sh && bash /remote/b.sh",
read_remote_script=remote,
)
is True
)
assert reads == ["/remote/a.sh"]
def test_cumulative_text_budget_bounds_recursive_scan(monkeypatch, tmp_path):
"""Two scripts individually under the per-file cap exceed the walk cap.
Relative references keep the root command short so the budget arithmetic
is about the scripts, not the tmp_path length."""
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 48)
(tmp_path / "a.sh").write_text("echo " + "a" * 10 + "\n", encoding="utf-8") # 16
(tmp_path / "b.sh").write_text("echo " + "b" * 10 + "\n", encoding="utf-8") # 16
cwd = str(tmp_path)
# 9 (root) + 16 fits in 48; 19 (root) + 16 + 16 does not → fail closed.
assert guard("bash a.sh", cwd=cwd) is False
assert guard("bash a.sh;bash b.sh", cwd=cwd) is True
def test_referenced_read_is_capped_at_remaining_budget(monkeypatch, tmp_path):
"""A file bigger than what the walk can still afford is never read whole:
the read helper receives the remaining budget as its cap."""
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 64)
(tmp_path / "big.sh").write_text("echo " + "x" * 200 + "\n", encoding="utf-8")
caps: list = []
original = lifecycle_guard._read_referenced_script
def spy(path, *, max_bytes=None):
caps.append(max_bytes)
return original(path, max_bytes=max_bytes)
monkeypatch.setattr(lifecycle_guard, "_read_referenced_script", spy)
root = "bash big.sh"
assert guard(root, cwd=str(tmp_path)) is True
assert caps == [64 - len(root)]
def test_remote_script_sanitizer_honours_remaining_budget():
text, unsafe = lifecycle_guard._sanitize_remote_script_text(
"echo ok\n", max_bytes=4
)
assert (text, unsafe) == (None, True)
text, unsafe = lifecycle_guard._sanitize_remote_script_text(
"echo ok\n", max_bytes=8
)
assert (text, unsafe) == ("echo ok\n", False)
def test_line_budget_fails_closed_before_tokenizing_every_line(
monkeypatch, tmp_path
):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINES", 4)
script = tmp_path / "many.sh"
script.write_text("echo ok\n" * 10, encoding="utf-8")
lexers = 0
real_shlex = lifecycle_guard.shlex.shlex
def counting(*args, **kwargs):
nonlocal lexers
lexers += 1
return real_shlex(*args, **kwargs)
monkeypatch.setattr(lifecycle_guard.shlex, "shlex", counting)
root = f"bash {script}"
assert guard(root) is True
# Only the one-line root was tokenized (a handful of lexers across the
# direct scans); the 10-line script never was.
assert 0 < lexers < 10
# --- scheduler entry point --------------------------------------------------
def test_check_gateway_lifecycle_shell_script_budget(monkeypatch, tmp_path):
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_BYTES", 8)
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 8)
script = tmp_path / "long-line.sh"
script.write_text("x" * 7, encoding="utf-8")
lifecycle_guard.check_gateway_lifecycle("", str(script))
script.write_text("x" * 9, encoding="utf-8")
with pytest.raises(lifecycle_guard.GatewayLifecycleBlocked):
lifecycle_guard.check_gateway_lifecycle("", str(script))
def test_check_gateway_lifecycle_python_path_charges_masker(monkeypatch, tmp_path):
"""The .py branch's data-exemption masker tokenizes too, so it is budgeted
and fails closed before shlex on an over-budget line."""
monkeypatch.setattr(lifecycle_guard, "_MAX_LIFECYCLE_SCAN_LINE_BYTES", 16)
small = tmp_path / "small.py"
small.write_text("x = 1\n", encoding="utf-8")
lifecycle_guard.check_gateway_lifecycle("run report", str(small))
monkeypatch.setattr(lifecycle_guard.shlex, "shlex", _explode)
long_line = tmp_path / "long.py"
long_line.write_text("x = 1\n" + "y" * 40 + "\n", encoding="utf-8")
with pytest.raises(lifecycle_guard.GatewayLifecycleBlocked):
lifecycle_guard.check_gateway_lifecycle("run report", str(long_line))
# --- no regression on realistic benign graphs ------------------------------
def test_default_budget_admits_a_wide_benign_wrapper_graph(tmp_path):
"""Issue #78398's shape: one wrapper invoking 200 small legitimate scripts
must still be allowed under the DEFAULT limits (an earlier fail-closed
attempt with a 64-path cap blocked exactly this)."""
children = []
for i in range(200):
child = tmp_path / f"c{i}.sh"
child.write_text("echo step && ls -la /tmp\n" * 20, encoding="utf-8")
children.append(child)
hub = tmp_path / "hub.sh"
hub.write_text("".join(f"bash {c}\n" for c in children), encoding="utf-8")
assert guard(f"bash {hub}") is False
# ...and a lifecycle command hidden behind the 200 benign scripts is still
# found: the budget bounds work, it does not stop the walk early.
evil = tmp_path / "evil.sh"
evil.write_text("hermes gateway restart\n", encoding="utf-8")
hub.write_text(hub.read_text() + f"bash {evil}\n", encoding="utf-8")
assert guard(f"bash {hub}") is True