From acbd94e19f56d004885453e110136d4c514fa797 Mon Sep 17 00:00:00 2001 From: exiao Date: Tue, 14 Jul 2026 16:19:21 -0400 Subject: [PATCH 1/2] fix(security): treat .run as a blanket-safe lookalike TLD This agent routinely hits .run hosts (Modal *.modal.run plus other .run services), and Tirith's lookalike_tld heuristic fires a MEDIUM warning on every one, gating routine commands behind an approval prompt. Previously only modal.run was carved out; all other .run hosts still warned. Add .run to _SAFE_LOOKALIKE_TLDS so it is suppressed like .app/.dev, but only as a terminal TLD token: a deeper unsafe suffix such as foo.run.evil.zip (real TLD .zip) is still not suppressed, and filename-collision phishing TLDs (.zip, .mov) are untouched. Remove the now-redundant _MODAL_RUN_RE. Tradeoff (intended): an arbitrary attacker.run no longer warns. Other Tirith rules (homograph, pipe-to-interpreter, etc.) are unaffected. Tests: tests/tools/test_tirith_security.py 117 passed. E2E against real check_command_security: bare .run -> allow, .run + pipe -> warn, .zip -> warn. Patch note: ~/.hermes/plans/hermes-patches/2026-07-14-tirith-allow-run-tld.md --- tests/tools/test_tirith_security.py | 48 ++++++++++++++++------------- tools/tirith_security.py | 46 ++++++++++----------------- 2 files changed, 43 insertions(+), 51 deletions(-) diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index 53b7e2893a48..a3a3490f2f3f 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -1451,19 +1451,20 @@ def test_matching_value_field(self): def test_matching_dev_tld(self): assert self.fn({"rule_id": "lookalike_tld", "value": ".dev"}) - def test_bare_run_tld_not_matched(self): - """A bare .run TLD token is the whole public Identity Digital registry, - not Modal — it must NOT be suppressed (only modal.run is).""" - assert not self.fn({"rule_id": "lookalike_tld", "value": ".run"}) + def test_bare_run_tld_suppressed(self): + """.run is now a blanket-safe terminal TLD (operator request), like + .app/.dev — a bare .run token is suppressed.""" + assert self.fn({"rule_id": "lookalike_tld", "value": ".run"}) - def test_run_message_field_not_matched(self): - """A generic '.run' TLD warning is not a Modal endpoint; keep the warn.""" - assert not self.fn({"rule_id": "lookalike_tld", - "message": "Domain uses '.run' TLD"}) + def test_run_message_field_suppressed(self): + """A generic '.run' TLD warning is suppressed now that .run is safe.""" + assert self.fn({"rule_id": "lookalike_tld", + "message": "Domain uses '.run' TLD"}) - def test_arbitrary_run_host_not_matched(self): - """An arbitrary .run host (potential phishing) is not suppressed.""" - assert not self.fn({"rule_id": "lookalike_tld", "value": "attacker.run"}) + def test_arbitrary_run_host_suppressed(self): + """Any terminal .run host is suppressed. This is the accepted tradeoff + of treating .run as safe: an arbitrary attacker.run no longer warns.""" + assert self.fn({"rule_id": "lookalike_tld", "value": "attacker.run"}) def test_matching_modal_run_domain(self): assert self.fn({"rule_id": "lookalike_tld", @@ -1472,21 +1473,23 @@ def test_matching_modal_run_domain(self): def test_matching_bare_modal_run_domain(self): assert self.fn({"rule_id": "lookalike_tld", "value": "modal.run"}) - def test_lookalike_modal_run_suffix_not_matched(self): - """A host that only ends the label with ...notmodal.run is not Modal.""" - assert not self.fn({"rule_id": "lookalike_tld", "value": "evilmodal.run"}) + def test_evilmodal_run_suppressed(self): + """Even a non-Modal terminal .run host is suppressed now (.run is a + blanket-safe TLD, no longer Modal-specific).""" + assert self.fn({"rule_id": "lookalike_tld", "value": "evilmodal.run"}) - def test_modal_run_deeper_suffix_not_matched(self): - """modal.run followed by a further risky TLD must not be suppressed.""" + def test_run_deeper_suffix_not_matched(self): + """A terminal .run followed by a further risky TLD must not be + suppressed — the real TLD is .zip, not .run.""" assert not self.fn({"rule_id": "lookalike_tld", "value": "modal.run.evil.zip"}) def test_run_as_subdomain_label_not_matched(self): """`.run` as a non-terminal label does not count as safe.""" assert not self.fn({"rule_id": "lookalike_tld", "value": "foo.run.example.zip"}) - def test_real_tirith_schema_modal_run_evidence(self): + def test_real_tirith_schema_run_evidence_suppressed(self): """Real Tirith schema-v3 output: the host lives in evidence[].raw and - the description is generic, so the Modal carve-out must read evidence.""" + the description is generic. A terminal .run host is suppressed.""" finding = { "rule_id": "lookalike_tld", "severity": "MEDIUM", @@ -1496,15 +1499,15 @@ def test_real_tirith_schema_modal_run_evidence(self): } assert self.fn(finding) - def test_real_tirith_schema_arbitrary_run_evidence_not_matched(self): - """Same schema, but an arbitrary .run host in evidence stays warned.""" + def test_real_tirith_schema_arbitrary_run_evidence_suppressed(self): + """Same schema, arbitrary .run host in evidence — also suppressed now.""" finding = { "rule_id": "lookalike_tld", "severity": "MEDIUM", "description": "Domain uses '.run' TLD which can be confused with file extensions", "evidence": [{"type": "url", "raw": "attacker.run"}], } - assert not self.fn(finding) + assert self.fn(finding) def test_real_tirith_schema_app_evidence(self): """A safe .app gTLD carried only in evidence[].raw is suppressed.""" @@ -1519,7 +1522,8 @@ def test_evidence_non_dict_items_tolerated(self): """Malformed evidence entries (non-dict) are scanned as strings, not fatal.""" assert self.fn({"rule_id": "lookalike_tld", "evidence": ["cpe-web.modal.run"]}) - assert not self.fn({"rule_id": "lookalike_tld", "evidence": ["attacker.run"]}) + # A terminal .run string is now suppressed too. + assert self.fn({"rule_id": "lookalike_tld", "evidence": ["attacker.run"]}) def test_matching_dev_message_field(self): assert self.fn({"rule_id": "lookalike_tld", diff --git a/tools/tirith_security.py b/tools/tirith_security.py index 205f91efb6bf..b330cb0d3376 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -857,18 +857,18 @@ def check_command_security(command: str) -> dict: return {"action": action, "findings": findings, "summary": summary} -# gTLDs that Tirith's lookalike_tld heuristic flags but which are legitimate, -# HTTPS-only registries in wide production use. .app and .dev are -# Google-operated gTLDs on the HSTS preload list (browsers force HTTPS), used -# by many production services (e.g. *.workers.dev, *.web.app). Suppressing -# their warnings removes false positives without weakening protection against -# filename-collision TLDs (.zip, .mov) used in real phishing. +# gTLDs that Tirith's lookalike_tld heuristic flags but which we treat as +# known false positives for this agent's normal workload. .app and .dev are +# Google-operated, HTTPS-only gTLDs on the HSTS preload list (browsers force +# HTTPS), used by many production services (e.g. *.workers.dev, *.web.app). # -# NOTE: .run is deliberately NOT in this list. Unlike .app/.dev it is a -# generic Identity Digital (Binky Moon) registry, not HTTPS-only, so blanket -# suppression would silence the lookalike warning for any .run host -# (e.g. ``attacker.run``). We only carve out Modal's own endpoints below. -_SAFE_LOOKALIKE_TLDS = (".app", ".dev") +# .run is included by operator request: this agent routinely hits .run hosts +# (Modal endpoints like *.modal.run, plus other .run services), and the +# "can be confused with a file extension" heuristic fires on every one. The +# terminal-token match below still refuses to suppress a deeper unsafe suffix +# (e.g. ``foo.run.evil.zip`` — the real TLD there is ``.zip``), so genuine +# filename-collision phishing TLDs (.zip, .mov) remain flagged. +_SAFE_LOOKALIKE_TLDS = (".app", ".dev", ".run") # Match a safe gTLD only as a complete TLD token, never as a substring of a # longer label. Without the trailing boundary, a substring check would let a @@ -882,32 +882,20 @@ def check_command_security(command: str) -> dict: r"(?![A-Za-z0-9_-])(?!\.\w)" ) -# Modal web endpoints all live under the ``modal.run`` registrable domain -# (e.g. ``cpe-research--cpe-web.modal.run``), a routine target for health -# checks and API calls from this agent, and the reason the lookalike_tld -# heuristic fires on ``.run`` in normal use. We suppress the warning only for -# this specific registrable domain — the host must be ``modal.run`` itself or -# end in ``.modal.run`` as a terminal label — so an arbitrary ``attacker.run`` -# still preserves the warn action. The trailing negative lookahead keeps a -# deeper suffix like ``modal.run.evil.zip`` from matching. -_MODAL_RUN_RE = re.compile(r"(?:^|[^A-Za-z0-9_.-])(?:[a-z0-9-]+\.)*modal\.run(?![A-Za-z0-9_.-])") - def _is_safe_lookalike_tld_finding(finding: dict) -> bool: """Return True if this finding is a lookalike_tld warning we treat as a - known false positive: a terminal ``.app`` / ``.dev`` gTLD, or a host under - Modal's ``modal.run`` registrable domain (see ``_SAFE_LOOKALIKE_TLDS`` / - ``_MODAL_RUN_RE``). + known false positive: a terminal ``.app`` / ``.dev`` / ``.run`` TLD (see + ``_SAFE_LOOKALIKE_TLDS``). Inspects the top-level string fields Tirith may use to carry the TLD/host (``value``/``tld``/``detail``/``description``/``message``) AND the ``evidence`` list, whose entries carry the actual matched host in a ``raw``/``value``/``url`` field (real Tirith schema v3 output puts the host only in ``evidence[].raw`` — the ``description`` is generic, e.g. "Domain - uses '.run' TLD ...", so the Modal carve-out would never fire without - reading evidence). The safe TLD must appear as a distinct terminal token; - substrings like ``example.dev.zip`` (where ``.zip`` is the real, unsafe - TLD) and arbitrary ``.run`` hosts such as ``attacker.run`` are not + uses '.run' TLD ...", so reading evidence is required). The safe TLD must + appear as a distinct terminal token; substrings like ``example.dev.zip`` or + ``foo.run.evil.zip`` (where ``.zip`` is the real, unsafe TLD) are not suppressed. """ if not isinstance(finding, dict): @@ -919,7 +907,7 @@ def _is_safe_text(val) -> bool: if val is None: return False text = str(val).lower() - return bool(_SAFE_TLD_RE.search(text) or _MODAL_RUN_RE.search(text)) + return bool(_SAFE_TLD_RE.search(text)) for field in ("value", "tld", "detail", "description", "message"): if _is_safe_text(finding.get(field)): From 42fd9e12e2321234fb00f60e9a9a4a0de700e58e Mon Sep 17 00:00:00 2001 From: exiao Date: Tue, 14 Jul 2026 22:27:05 -0400 Subject: [PATCH 2/2] fix(security): restrict .run lookalike suppression to Modal --- tests/tools/test_tirith_security.py | 71 +++++++++++++++++++---------- tools/tirith_security.py | 37 +++++++++------ 2 files changed, 71 insertions(+), 37 deletions(-) diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index a3a3490f2f3f..664cfe4b7015 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -1397,6 +1397,31 @@ def test_dev_lookalike_tld_suppressed(self, mock_cfg, mock_run): assert result["action"] == "allow" assert result["findings"] == [] + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_modal_run_lookalike_domain_suppressed(self, mock_cfg, mock_run): + """modal.run and its subdomains are trusted .run false positives.""" + mock_cfg.return_value = _CFG + findings = [{"rule_id": "lookalike_tld", + "evidence": [{"raw": "cpe-research--cpe-web.modal.run"}], + "message": "Domain uses '.run' TLD"}] + mock_run.return_value = _mock_run(2, _json_stdout(findings, ".run TLD warning")) + result = check_command_security("curl https://cpe-research--cpe-web.modal.run") + assert result["action"] == "allow" + assert result["findings"] == [] + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_arbitrary_run_lookalike_tld_preserved(self, mock_cfg, mock_run): + """Regression: .run is generic/open and must not be blanket-suppressed.""" + mock_cfg.return_value = _CFG + findings = [{"rule_id": "lookalike_tld", "value": "malicious-installer.run", + "message": "Domain uses '.run' TLD"}] + mock_run.return_value = _mock_run(2, _json_stdout(findings, ".run TLD warning")) + result = check_command_security("curl https://malicious-installer.run") + assert result["action"] == "warn" + assert len(result["findings"]) == 1 + @patch("tools.tirith_security.subprocess.run") @patch("tools.tirith_security._load_security_config") def test_mov_lookalike_tld_preserved(self, mock_cfg, mock_run): @@ -1451,32 +1476,33 @@ def test_matching_value_field(self): def test_matching_dev_tld(self): assert self.fn({"rule_id": "lookalike_tld", "value": ".dev"}) - def test_bare_run_tld_suppressed(self): - """.run is now a blanket-safe terminal TLD (operator request), like - .app/.dev — a bare .run token is suppressed.""" - assert self.fn({"rule_id": "lookalike_tld", "value": ".run"}) + def test_bare_run_tld_not_suppressed(self): + """A bare .run token is not enough: .run is an open generic TLD.""" + assert not self.fn({"rule_id": "lookalike_tld", "value": ".run"}) - def test_run_message_field_suppressed(self): - """A generic '.run' TLD warning is suppressed now that .run is safe.""" - assert self.fn({"rule_id": "lookalike_tld", - "message": "Domain uses '.run' TLD"}) + def test_run_message_field_not_suppressed_without_trusted_domain(self): + """Generic '.run' warnings stay active without modal.run evidence.""" + assert not self.fn({"rule_id": "lookalike_tld", + "message": "Domain uses '.run' TLD"}) - def test_arbitrary_run_host_suppressed(self): - """Any terminal .run host is suppressed. This is the accepted tradeoff - of treating .run as safe: an arbitrary attacker.run no longer warns.""" - assert self.fn({"rule_id": "lookalike_tld", "value": "attacker.run"}) + def test_arbitrary_run_host_not_suppressed(self): + """Regression: arbitrary .run hosts still warn (not blanket-safe).""" + assert not self.fn({"rule_id": "lookalike_tld", "value": "attacker.run"}) + assert not self.fn({"rule_id": "lookalike_tld", "value": "evil.run"}) def test_matching_modal_run_domain(self): assert self.fn({"rule_id": "lookalike_tld", "value": "cpe-research--cpe-web.modal.run"}) + def test_matching_modal_run_subdomain(self): + assert self.fn({"rule_id": "lookalike_tld", "value": "foo.modal.run"}) + def test_matching_bare_modal_run_domain(self): assert self.fn({"rule_id": "lookalike_tld", "value": "modal.run"}) - def test_evilmodal_run_suppressed(self): - """Even a non-Modal terminal .run host is suppressed now (.run is a - blanket-safe TLD, no longer Modal-specific).""" - assert self.fn({"rule_id": "lookalike_tld", "value": "evilmodal.run"}) + def test_evilmodal_run_not_suppressed(self): + """Only modal.run's registrable domain and subdomains are trusted.""" + assert not self.fn({"rule_id": "lookalike_tld", "value": "evilmodal.run"}) def test_run_deeper_suffix_not_matched(self): """A terminal .run followed by a further risky TLD must not be @@ -1487,9 +1513,9 @@ def test_run_as_subdomain_label_not_matched(self): """`.run` as a non-terminal label does not count as safe.""" assert not self.fn({"rule_id": "lookalike_tld", "value": "foo.run.example.zip"}) - def test_real_tirith_schema_run_evidence_suppressed(self): + def test_real_tirith_schema_run_evidence_suppressed_for_modal(self): """Real Tirith schema-v3 output: the host lives in evidence[].raw and - the description is generic. A terminal .run host is suppressed.""" + the description is generic. A modal.run host is suppressed.""" finding = { "rule_id": "lookalike_tld", "severity": "MEDIUM", @@ -1499,15 +1525,15 @@ def test_real_tirith_schema_run_evidence_suppressed(self): } assert self.fn(finding) - def test_real_tirith_schema_arbitrary_run_evidence_suppressed(self): - """Same schema, arbitrary .run host in evidence — also suppressed now.""" + def test_real_tirith_schema_arbitrary_run_evidence_not_suppressed(self): + """Same schema, arbitrary .run host in evidence — still warns.""" finding = { "rule_id": "lookalike_tld", "severity": "MEDIUM", "description": "Domain uses '.run' TLD which can be confused with file extensions", "evidence": [{"type": "url", "raw": "attacker.run"}], } - assert self.fn(finding) + assert not self.fn(finding) def test_real_tirith_schema_app_evidence(self): """A safe .app gTLD carried only in evidence[].raw is suppressed.""" @@ -1522,8 +1548,7 @@ def test_evidence_non_dict_items_tolerated(self): """Malformed evidence entries (non-dict) are scanned as strings, not fatal.""" assert self.fn({"rule_id": "lookalike_tld", "evidence": ["cpe-web.modal.run"]}) - # A terminal .run string is now suppressed too. - assert self.fn({"rule_id": "lookalike_tld", "evidence": ["attacker.run"]}) + assert not self.fn({"rule_id": "lookalike_tld", "evidence": ["attacker.run"]}) def test_matching_dev_message_field(self): assert self.fn({"rule_id": "lookalike_tld", diff --git a/tools/tirith_security.py b/tools/tirith_security.py index b330cb0d3376..c1fa2bc440b3 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -862,13 +862,11 @@ def check_command_security(command: str) -> dict: # Google-operated, HTTPS-only gTLDs on the HSTS preload list (browsers force # HTTPS), used by many production services (e.g. *.workers.dev, *.web.app). # -# .run is included by operator request: this agent routinely hits .run hosts -# (Modal endpoints like *.modal.run, plus other .run services), and the -# "can be confused with a file extension" heuristic fires on every one. The -# terminal-token match below still refuses to suppress a deeper unsafe suffix -# (e.g. ``foo.run.evil.zip`` — the real TLD there is ``.zip``), so genuine -# filename-collision phishing TLDs (.zip, .mov) remain flagged. -_SAFE_LOOKALIKE_TLDS = (".app", ".dev", ".run") +# Do not blanket-suppress generic open registries such as .run: those collide +# with common executable extensions (installer.run, setup.run). Trusted .run +# services must be allowlisted by registrable domain below instead. +_SAFE_LOOKALIKE_TLDS = (".app", ".dev") +_SAFE_LOOKALIKE_DOMAINS = ("modal.run",) # Match a safe gTLD only as a complete TLD token, never as a substring of a # longer label. Without the trailing boundary, a substring check would let a @@ -882,21 +880,32 @@ def check_command_security(command: str) -> dict: r"(?![A-Za-z0-9_-])(?!\.\w)" ) +# Match trusted registrable domains, plus any subdomain, only when the trusted +# domain is the terminal host suffix. This suppresses ``modal.run`` and +# ``foo.modal.run`` but not ``evilmodal.run`` or ``modal.run.evil.zip``. +_SAFE_DOMAIN_RE = re.compile( + r"(? bool: """Return True if this finding is a lookalike_tld warning we treat as a - known false positive: a terminal ``.app`` / ``.dev`` / ``.run`` TLD (see - ``_SAFE_LOOKALIKE_TLDS``). + known false positive: a terminal ``.app``/``.dev`` TLD or a trusted + registrable domain such as ``modal.run`` and its subdomains. Inspects the top-level string fields Tirith may use to carry the TLD/host (``value``/``tld``/``detail``/``description``/``message``) AND the ``evidence`` list, whose entries carry the actual matched host in a ``raw``/``value``/``url`` field (real Tirith schema v3 output puts the host only in ``evidence[].raw`` — the ``description`` is generic, e.g. "Domain - uses '.run' TLD ...", so reading evidence is required). The safe TLD must - appear as a distinct terminal token; substrings like ``example.dev.zip`` or - ``foo.run.evil.zip`` (where ``.zip`` is the real, unsafe TLD) are not - suppressed. + uses '.run' TLD ...", so reading evidence is required). The safe TLD/domain + must appear as a distinct terminal token; substrings like + ``example.dev.zip``, ``evilmodal.run``, or ``foo.run.evil.zip`` (where + ``.zip`` is the real, unsafe TLD) are not suppressed. """ if not isinstance(finding, dict): return False @@ -907,7 +916,7 @@ def _is_safe_text(val) -> bool: if val is None: return False text = str(val).lower() - return bool(_SAFE_TLD_RE.search(text)) + return bool(_SAFE_TLD_RE.search(text) or _SAFE_DOMAIN_RE.search(text)) for field in ("value", "tld", "detail", "description", "message"): if _is_safe_text(finding.get(field)):