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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user