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
+43 -5
View File
@@ -2106,13 +2106,48 @@ class AIAgent:
pass pass
return True # safe default: verifier on return True # safe default: verifier on
@staticmethod # Bare absolute / home / Windows-drive file paths in a footer line.
def _format_file_mutation_failure_footer(failed: Dict[str, Dict[str, Any]]) -> str: # Anchors mirror the gateway's ``extract_local_files`` bare-path
# detector so that anything the gateway WOULD auto-attach is wrapped
# in inline-code backticks here first (the extractor skips paths inside
# `code` spans). Defense-in-depth: even if a future error message
# echoes a credential path (config.yaml, .env, auth.json) into the
# user-facing footer, it can never be matched as a deliverable bare
# path and silently uploaded to a messaging channel (#35584).
_FOOTER_PATH_RE = re.compile(
r"(?<![/:\w.`])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.[\w]+",
)
@classmethod
def _neutralize_footer_paths(cls, text: str) -> str:
"""Wrap bare file paths in backticks so they aren't auto-delivered.
The gateway's ``extract_local_files`` scans response text for bare
absolute/home paths ending in a deliverable extension and uploads
any that exist on disk as native attachments — but it explicitly
skips paths inside inline-code (`` `...` ``) spans. Backticking
every path the footer renders defeats that auto-detection while
keeping the path fully human-readable. Paths already wrapped in a
backtick (the negative lookbehind excludes a preceding `` ` ``) are
left untouched so we never double-wrap.
"""
if not text:
return text
return cls._FOOTER_PATH_RE.sub(lambda m: f"`{m.group(0)}`", text)
@classmethod
def _format_file_mutation_failure_footer(cls, failed: Dict[str, Dict[str, Any]]) -> str:
"""Render the per-turn failed-mutation dict as a user-facing footer. """Render the per-turn failed-mutation dict as a user-facing footer.
Displays up to 10 paths with their first error preview, then a Displays up to 10 paths with their first error preview, then a
count of any additional failures. Returns an empty string when count of any additional failures. Returns an empty string when
the dict is empty so callers can concatenate unconditionally. the dict is empty so callers can concatenate unconditionally.
Every file path that reaches the user-facing text — both the bullet
path and any path echoed inside the tool's error preview — is
backtick-wrapped via ``_neutralize_footer_paths`` so the gateway's
bare-path media extractor can never auto-attach a protected file
(e.g. ``~/.hermes/config.yaml``) to a messaging channel (#35584).
""" """
if not failed: if not failed:
return "" return ""
@@ -2129,14 +2164,17 @@ class AIAgent:
preview = (info.get("error_preview") or "").strip() preview = (info.get("error_preview") or "").strip()
tool = info.get("tool") or "patch" tool = info.get("tool") or "patch"
if preview: if preview:
lines.append(f"{path} — [{tool}] {preview}") lines.append(f"`{path}` — [{tool}] {preview}")
else: else:
lines.append(f"{path} — [{tool}] failed") lines.append(f"`{path}` — [{tool}] failed")
shown += 1 shown += 1
remaining = len(failed) - shown remaining = len(failed) - shown
if remaining > 0: if remaining > 0:
lines.append(f" • … and {remaining} more") lines.append(f" • … and {remaining} more")
return "\n".join(lines) # Neutralize any path the preview text echoed (the bullet path is
# already backticked above; the lookbehind keeps it from being
# double-wrapped).
return cls._neutralize_footer_paths("\n".join(lines))
def _turn_completion_explainer_enabled(self) -> bool: def _turn_completion_explainer_enabled(self) -> bool:
"""Check whether the end-of-turn completion explainer footer is on. """Check whether the end-of-turn completion explainer footer is on.
@@ -300,6 +300,57 @@ class TestFormatFooter:
bullet_lines = [ln for ln in lines if ln.lstrip().startswith("")] bullet_lines = [ln for ln in lines if ln.lstrip().startswith("")]
assert len(bullet_lines) == 11 # 10 shown + 1 summary 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 # _file_mutation_verifier_enabled — env + config precedence