fix(security): neutralize file paths in mutation-verifier footer (#35584) (#35684)

The per-turn file-mutation verifier footer rendered failed-write paths as
bare absolute paths in the user-facing response. The gateway's
extract_local_files() scans response text for bare paths ending in a
deliverable extension (.yaml/.json/etc.), validates os.path.isfile(), and
auto-attaches matches as native uploads — so a denied write to
~/.hermes/config.yaml surfaced the path in the footer and got the
credential file silently uploaded to the messaging channel.

The gateway denylist (validate_media_delivery_path) already blocks the
config.yaml case after #35634. This is defense-in-depth at the source:
backtick-wrap every path the footer emits — both the bullet path and any
path echoed inside the tool's error preview (the protected-file denial
message embeds the path in single quotes, which do NOT block the
extractor regex). extract_local_files skips paths inside inline-code
spans, so wrapping defeats auto-attachment for ANY protected file while
keeping the path human-readable.

- run_agent.py: _format_file_mutation_failure_footer wraps bullet paths;
  new _neutralize_footer_paths backticks any remaining bare path (covers
  the preview echo). staticmethod -> classmethod (caller unaffected).
- tests: backtick-wrap assertion + end-to-end extract_local_files leak test.
This commit is contained in:
Teknium
2026-05-30 23:05:23 -07:00
committed by GitHub
parent dc4de14377
commit 9b78f411c8
2 changed files with 94 additions and 5 deletions

View File

@ -300,6 +300,57 @@ class TestFormatFooter:
bullet_lines = [ln for ln in lines if ln.lstrip().startswith("")]
assert len(bullet_lines) == 11 # 10 shown + 1 summary
def test_paths_are_backtick_wrapped(self):
"""Footer paths must be inline-code wrapped so the gateway's bare-path
media extractor can't auto-attach them (#35584 defense-in-depth)."""
out = AIAgent._format_file_mutation_failure_footer(
{"/home/u/.hermes/config.yaml": {
"tool": "patch",
"error_preview": (
"Write denied: '/home/u/.hermes/config.yaml' is a "
"protected system/credential file."
),
}},
)
# Path still human-readable.
assert "/home/u/.hermes/config.yaml" in out
# Bullet path is backticked.
assert "`/home/u/.hermes/config.yaml`" in out
# The path echoed inside the preview is ALSO backticked (the real
# file_operations.py denial message embeds it in single quotes, which
# do NOT block the gateway extractor's regex).
assert "'`/home/u/.hermes/config.yaml`'" in out
# No double-backticking anywhere.
assert "``" not in out
def test_footer_path_not_extracted_by_gateway(self):
"""End-to-end: the gateway's extract_local_files must NOT pull a
config.yaml path out of the rendered footer (#35584)."""
import os
import tempfile
from gateway.platforms.base import BasePlatformAdapter
tmp = tempfile.mkdtemp(prefix="hermes_footer_")
try:
cfg = os.path.join(tmp, "config.yaml")
with open(cfg, "w") as fh:
fh.write("openrouter_api_key: sk-LEAK\n")
footer = AIAgent._format_file_mutation_failure_footer(
{cfg: {
"tool": "patch",
"error_preview": (
f"Write denied: '{cfg}' is a protected "
"system/credential file."
),
}},
)
response = "I updated your config.\n\n" + footer
paths, _ = BasePlatformAdapter.extract_local_files(response)
assert paths == [], f"footer leaked deliverable path(s): {paths}"
finally:
import shutil
shutil.rmtree(tmp, ignore_errors=True)
# ---------------------------------------------------------------------------
# _file_mutation_verifier_enabled — env + config precedence