feat(egress): host hardening survey — follow-up to #35149#4
Conversation
Add `hermes egress harden`: a read-only host-hardening survey that detects
10 perimeter-security signals (Tailscale, UFW, firewalld, nftables,
fail2ban, SSH password/root-login config, Docker seccomp) plus the two
iron-proxy runtime signals, and shows how they layer with iron-proxy's
sandbox-egress hardening.
- New module agent/proxy_sources/host_hardening.py (stdlib-only, no side
effects, graceful skip on missing binaries / non-Linux hosts).
- New CLI subcommand `hermes egress harden` with --baseline
{minimal,catalin,paranoid}, --json, --all. Always exits 0 (informational).
- 25 hermetic tests in tests/test_iron_proxy_harden.py.
- New docs page hardening-baselines.md + links from iron-proxy.md and
cli-commands.md.
Complementary to `hermes egress doctor` (proxy health) — this answers
'is the host the proxy runs on locked down?'. Inspired by @catalinmpit's
Hetzner+Tailscale+UFW+fail2ban deployment that prompted Teknium's
security-review question.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Table shows redundant status text instead of detail
- The harden table now labels the column as Detail and renders each signal's human-readable detail text.
- ✅ Fixed: Substring match falsely detects stopped firewalld as running
- The firewalld probe now requires exit code 0 and an exact running state before reporting pass.
Or push these changes by commenting:
@cursor push 4a0184a6d6
Preview (4a0184a6d6)
diff --git a/agent/proxy_sources/host_hardening.py b/agent/proxy_sources/host_hardening.py
--- a/agent/proxy_sources/host_hardening.py
+++ b/agent/proxy_sources/host_hardening.py
@@ -191,7 +191,8 @@
name, SKIP, "firewalld not installed",
"install firewalld or use ufw/nftables instead",
)
- if "running" in out.strip().lower():
+ state = out.strip().lower()
+ if rc == 0 and state == "running":
return HardeningSignal(name, PASS, "running")
return HardeningSignal(
name, FAIL, f"not running ({out.strip() or 'unknown'})",
diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py
--- a/hermes_cli/proxy_cli.py
+++ b/hermes_cli/proxy_cli.py
@@ -830,13 +830,13 @@
padding=(0, 1))
table.add_column("", width=2)
table.add_column("Signal", style="cyan", no_wrap=True)
- table.add_column("Status")
+ table.add_column("Detail")
table.add_column("Action")
for s in shown:
table.add_row(
_glyph.get(s.status, "?"),
s.name,
- s.status,
+ s.detail,
s.fix or "\u2014",
)
if shown:
diff --git a/tests/test_iron_proxy_harden.py b/tests/test_iron_proxy_harden.py
--- a/tests/test_iron_proxy_harden.py
+++ b/tests/test_iron_proxy_harden.py
@@ -90,6 +90,13 @@
assert s.status == hh.PASS
+def test_firewalld_not_running_fails(monkeypatch, no_iron_proxy):
+ _force_linux(monkeypatch)
+ _patch_run(monkeypatch, {"firewall-cmd": (252, "not running\n", "")})
+ s = _signal(hh.survey_host(), "firewalld")
+ assert s.status == hh.FAIL
+
+
def test_nftables_pass(monkeypatch, no_iron_proxy):
_force_linux(monkeypatch)
_patch_run(monkeypatch, {
@@ -293,6 +300,30 @@
assert "ssh-password-auth" not in default_out
+def test_table_shows_signal_detail(monkeypatch, capsys):
+ import argparse
+ from hermes_cli import proxy_cli
+
+ signals = [
+ hh.HardeningSignal(
+ "firewalld",
+ hh.FAIL,
+ "not running (not running)",
+ "systemctl enable --now firewalld",
+ ),
+ ]
+ monkeypatch.setattr(proxy_cli.hh, "survey_host", lambda baseline: signals)
+ monkeypatch.setattr(
+ proxy_cli.hh,
+ "baseline_status",
+ lambda signals, baseline: (False, ["firewalld"]),
+ )
+ proxy_cli.cmd_harden(argparse.Namespace(
+ baseline="minimal", as_json=False, show_all=False))
+ out = capsys.readouterr().out
+ assert "not running (not running)" in out
+
+
# ---------------------------------------------------------------------------
# Platform + missing-file graceful degradation
# ---------------------------------------------------------------------------You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 1b921b1. Configure here.
| table.add_row( | ||
| _glyph.get(s.status, "?"), | ||
| s.name, | ||
| s.status, |
There was a problem hiding this comment.
Table shows redundant status text instead of detail
Medium Severity
The cmd_harden table renders s.status (bare text like "fail"/"warn"/"skip") in the third column, which is entirely redundant with the glyph already in column 1. The s.detail field — containing the actually useful human explanation like "ufw installed but inactive" or "BackendState=Running" — is never displayed in non-JSON mode. The sibling cmd_doctor handler correctly shows c.detail in the equivalent position.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1b921b1. Configure here.
| "install firewalld or use ufw/nftables instead", | ||
| ) | ||
| if "running" in out.strip().lower(): | ||
| return HardeningSignal(name, PASS, "running") |
There was a problem hiding this comment.
Substring match falsely detects stopped firewalld as running
High Severity
The check "running" in out.strip().lower() is a substring match that incorrectly evaluates to True when firewall-cmd --state outputs "not running" to stdout (which it does with exit code 252 when the daemon is stopped). This causes the signal to report PASS when firewalld is actually not running, completely defeating the purpose of the firewall probe.
Reviewed by Cursor Bugbot for commit 1b921b1. Configure here.
|
Superseded by upstream PR NousResearch#35187 — retargeted to NousResearch/hermes-agent feat/iron-proxy branch with Bugbot fixes baked in. Bartok9/hermes-agent is just a fork; landing on upstream is what we want. |
…NousResearch#34192) (NousResearch#34382) NousResearch#34192 reports Hostinger's 'Hermes WebUI' catalog crashes on startup with: /usr/bin/tini: No such file or directory The image moved from tini to s6-overlay as PID 1 (/init) earlier in 2026. Orchestration templates that still pin /usr/bin/tini as the entrypoint \u2014 like the Hostinger Hermes WebUI catalog \u2014 have no binary to exec and the container crashes immediately. Hermes has no control over the Hostinger catalog template, but we can make the image backward-compatible by symlinking /usr/bin/tini -> /init during the s6-overlay install step. External wrappers that exec /usr/bin/tini will land on the same s6-overlay reaper they would have landed on if they'd used the canonical /init entrypoint. The image's own ENTRYPOINT continues to be /init verbatim \u2014 the shim is purely for legacy external wrappers, not for the image's own runtime path. Once affected catalogs are updated, the symlink can be removed. Other issues NousResearch#34192 raises that are NOT addressed by this PR: * Problem #2 (UID 1024 vs 10000 mismatch): already fixed by NousResearch#33148 (S6_KEEP_ENV=1) and NousResearch#32412 (with-contenv shebangs). The Hostinger template likely needs to update its env-var propagation. * Problem #3 (incompatible session formats): RFC for pluggable SessionDB is tracked in NousResearch#23717. * Problem #4 (Telegram polling conflict): an operations problem on Hostinger's side, not in this codebase. This PR is scoped to the one issue that can be fixed inside Dockerfile: the missing /usr/bin/tini binary. Tests (3 in test_dockerfile_tini_compat_shim.py): - test_tini_compat_symlink_present Guard: the symlink line must exist in Dockerfile. - test_tini_compat_comment_explains_why The NousResearch#34192 anchor comment must be present so future readers know why the shim is there (avoid accidental removal). - test_entrypoint_still_init_not_tini Sanity check: ENTRYPOINT remains /init (s6-overlay). The shim is only for external wrappers. Refs: NousResearch#34192 Partial fix: addresses the immediate tini-binary crash. Catalog-side fixes still needed by Hostinger for the UID and session-format problems documented in the issue. Co-authored-by: Cursor <cursoragent@cursor.com>



Why this complements NousResearch#30179 and NousResearch#35149
NousResearch#30179 introduced the iron-proxy sandbox-egress layer (swap opaque proxy tokens for real upstream creds at the network boundary, so a prompt-injected agent never sees real keys). NousResearch#35149 added
hermes egress doctorto answer "is the egress proxy itself healthy?".This PR adds the missing half of the picture: host-perimeter hardening.
hermes egress hardenanswers "is the machine the proxy runs on locked down?" — firewall, SSH config, fail2ban, mesh-VPN, Docker seccomp — and folds the two iron-proxy runtime signals in so an operator sees the whole defense-in-depth stack in one table.The two layers solve different threats and neither substitutes for the other:
OPENAI_API_KEYto an attacker. iron-proxy stops that.Stack: NousResearch#30179 → NousResearch#35149 → this PR. Parallel-independent to the CA-rotation sibling — no file overlap.
What lands
agent/proxy_sources/host_hardening.py— stdlib-only, no side effects, every probe graceful (missing binary / non-Linux host →skip, neverfail). Reusesget_status()fromiron_proxy.pyfor signals 8 + 9 (the only touch to the existing proxy code is afromimport).hermes egress harden(one sub-parser entry + one handlercmd_hardeninhermes_cli/proxy_cli.py):--baseline {minimal,catalin,paranoid}(defaultminimal)--json→{"signals":[{name,status,detail,fix}],"baseline":str,"satisfied":bool,"missing":[str]}--all→ show passing signals too (default: gaps-only)tests/test_iron_proxy_harden.py(mirrorstest_iron_proxy_doctor.pystyle).website/docs/user-guide/egress/hardening-baselines.md+ a "Host hardening" section iniron-proxy.mdand ahardenblock incli-commands.md.New surfaces
host_hardening.HardeningSignalname / status / fix(Optional)+detail;to_dict()for--jsonhost_hardening.survey_host(*, baseline="minimal")list[HardeningSignal](always probes all 10)host_hardening.baseline_status(signals, baseline)(satisfied, missing)— pure evaluationhost_hardening.BASELINES / BASELINE_NAMES / SIGNAL_NAMEShermes egress harden--baseline / --json / --all, exit 0The 10 signals
tailscaletailscale status --json→BackendState=Runningufwufw status verbose→ active + default-deny incomingfirewalldfirewall-cmd --state→ runningnftablesnft list ruleset→ non-emptyfail2banfail2ban-client status→ ≥1 jailssh-password-auth^PasswordAuthentication noin sshd_configssh-root-login^PermitRootLogin (no|prohibit-password)iron-proxy-enabledget_status()iron-proxy-runningget_status()(pid alive + listening)docker-seccompdocker infoSecurityOptions includesseccompBaselines (color the summary line only — never force a fail):
Failure modes considered
_run()helper; a hang →skip, never a wedged survey.ufw / firewalld / nftables / fail2banskip cleanly on macOS; Tailscale / SSH-config / iron-proxy / Docker still run./etc/ssh/sshd_config— both SSH signalsskip(notfail); many dev boxes / minimal containers don't run sshd.get_status()raising — wrapped; the iron-proxy signals degrade toskip, never crash the survey.tailscalenon-JSON →warn;nft/dockernon-zero exit →warnwith an actionable hint.Validation
test_iron_proxy_harden.pyReal survey on a macOS dev host (
hermes egress harden --json) runs clean, exit 0, with Linux-only signals degrading toskip— confirms graceful cross-platform behavior.Coverage gaps
enabledsignal treats a generated CA +proxy.yamlas "enabled" becauseget_status()doesn't readconfig.yaml'sproxy.enabled. An explicitenabled=Truealso passes. Documented inline.nft list rulesetwithout root returns a non-zero exit →warn(we can't distinguish "no ruleset" from "needs root"); the hint says to re-run withsudo.Ambiguity flags
get_status()exposes.enabled(dataclass defaultFalse, not populated from config) and.configured(CA + proxy.yaml present). I treatedenabled OR configuredas PASS so the signal is useful without a full config load. If you want strictconfig.yaml: proxy.enabledsemantics, that's a one-line change.hardenas its own command tree vs. a doctor check — per the brief I kept it a separate command (hermes egress harden), NOT a newdoctorcheck, since it surveys the host rather than the proxy. They're complementary.missinglist reports all acceptable members so the operator sees every valid fix. Open to reporting just a representative one if that's noisy.Cut from scope (known follow-ups)
Documented in
hardening-baselines.mdunder "Future enhancements":iptables -Lbaselineuserns-remap)Attribution
Opened by Bartok at Daniel's request, in response to Teknium's invitation to @catalinmpit for a security review of the egress proxy (NousResearch#30179). Teknium's prompt was essentially "can we get a security review of how Hermes' egress proxy holds up in a real deployment?" — and Catalin's public deployment described the shape this PR encodes as the
catalinbaseline:This PR turns that ad-hoc perimeter into a first-class, surveyable baseline that composes with the existing sandbox-egress isolation.
Note
Low Risk
Read-only probes and an informational CLI that always exit 0; no changes to auth, egress enforcement, or proxy startup gating.
Overview
Adds
hermes egress harden, a read-only host security survey that complementshermes egress doctor: it probes perimeter controls (Tailscale, UFW/firewalld/nftables, fail2ban, SSH settings, Docker seccomp) and two iron-proxy runtime checks viaget_status(), then reports pass/fail/warn/skip with optional remediation hints.New
agent/proxy_sources/host_hardening.pyimplements ten stdlib-only probes (4s timeouts, missing tools →skip),survey_host(), and baseline evaluation with OR-groups for “any firewall” onminimal,catalin, andparanoid.hermes_cli/proxy_cli.pywires the subcommand with--baseline,--json, and--all(gaps-only by default); the command always exits 0 (informational, does not gate deploys).25 hermetic tests in
tests/test_iron_proxy_harden.pyand docs (hardening-baselines.md, CLI reference, iron-proxy cross-link) document signals and baselines.Reviewed by Cursor Bugbot for commit 1b921b1. Bugbot is set up for automated code reviews on this repo. Configure here.