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:
48
run_agent.py
48
run_agent.py
@ -2106,13 +2106,48 @@ class AIAgent:
|
||||
pass
|
||||
return True # safe default: verifier on
|
||||
|
||||
@staticmethod
|
||||
def _format_file_mutation_failure_footer(failed: Dict[str, Dict[str, Any]]) -> str:
|
||||
# Bare absolute / home / Windows-drive file paths in a footer line.
|
||||
# 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.
|
||||
|
||||
Displays up to 10 paths with their first error preview, then a
|
||||
count of any additional failures. Returns an empty string when
|
||||
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:
|
||||
return ""
|
||||
@ -2129,14 +2164,17 @@ class AIAgent:
|
||||
preview = (info.get("error_preview") or "").strip()
|
||||
tool = info.get("tool") or "patch"
|
||||
if preview:
|
||||
lines.append(f" • {path} — [{tool}] {preview}")
|
||||
lines.append(f" • `{path}` — [{tool}] {preview}")
|
||||
else:
|
||||
lines.append(f" • {path} — [{tool}] failed")
|
||||
lines.append(f" • `{path}` — [{tool}] failed")
|
||||
shown += 1
|
||||
remaining = len(failed) - shown
|
||||
if remaining > 0:
|
||||
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:
|
||||
"""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("•")]
|
||||
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