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