Files
hermes-agent/agent/context_references.py
kshitijk4poor fc53717f72 fix(files): one live-DB refusal source; hold the lock through /api/files/read
Gate r2 Low cleanups (house rule: no aliases/shims):
- Drop the is_live_database_file alias; its point-in-time caveat now lives on
  has_live_connection.
- _refuse_live_database reuses offline_file_access's message (via _serve_offline),
  so a download 409 on state.db-shm names the main database like the read path;
  the verb is "serve" so it fits read/download/stream.
- /api/files/read reads whole files in-process, so _read_base64_file now holds
  offline_file_access through close (409 on a live DB; OSError stays 500). Only
  the streamed FileResponse routes keep the point-in-time check.
- That check takes the global _live_lock, which other threads hold across
  whole-file reads, so fs_download and the managed stream routes run it via
  asyncio.to_thread instead of stalling the event loop.
- _managed_readable_file docstring no longer claims a size cap;
  _read_file_reference returns (early, text) instead of a str|Expansion union
  sniffed with isinstance.

Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
2026-09-27 20:45:56 +05:30

700 lines
33 KiB
Python

"""@-reference expansion (``@file:``, ``@folder:``, ``@diff``, ``@git:``, ``@url:`` + plugin prefixes)."""
from __future__ import annotations
import asyncio
import inspect
import json
import mimetypes
import os
import re
import subprocess
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Awaitable, Callable
from agent.model_metadata import CHARS_PER_TOKEN, estimate_tokens_rough
from hermes_cli._subprocess_compat import IS_WINDOWS, harden_git_argv, noninteractive_git_env, windows_hide_flags
from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access
from hermes_cli.sizefmt import format_bytes
# ── Plugin context-reference provider API ────────────────────────────────────
# --------------------------------------------------------------------------- Plugin context-reference
# provider API (Issue #26193) ---------------------------------------------------------------------------
BUILTIN_PREFIXES = frozenset({"diff", "staged", "file", "folder", "git", "url"})
_context_reference_providers: dict[str, "ContextReferenceProvider"] = {}
class ContextCompletionItem:
"""A single autocomplete result from a context reference provider."""
__slots__ = ("text", "display", "meta")
def __init__(self, text: str, display: str = "", meta: str = "") -> None:
self.text = text
self.display = display or text
self.meta = meta
class ContextReferenceProvider(ABC):
"""Base class for plugin @-prefix providers, registered via ``PluginContext.register_context_reference()``."""
prefix: str = "" # e.g. "issue", "channel", "doc"
description: str = "" # shown in autocomplete meta column
@abstractmethod
async def autocomplete(self, query: str, *, limit: int = 10) -> list[ContextCompletionItem]:
"""Return autocomplete items for the given query string."""
@abstractmethod
async def expand(self, target: str) -> str | None:
"""Expand *target* to prompt content. Return ``None`` to skip."""
def register_context_reference_provider(provider: ContextReferenceProvider) -> None:
"""Register a plugin context reference provider."""
if not isinstance(provider, ContextReferenceProvider):
raise TypeError("provider must be a ContextReferenceProvider instance")
prefix = provider.prefix.lower().strip()
if not prefix:
raise ValueError("prefix must be a non-empty string")
if prefix in BUILTIN_PREFIXES:
raise ValueError(f"prefix '{prefix}' is reserved for built-in references")
if prefix in _context_reference_providers:
raise ValueError(f"prefix '{prefix}' is already registered")
_context_reference_providers[prefix] = provider
def get_context_reference_providers() -> dict[str, ContextReferenceProvider]:
"""Return a snapshot of all registered plugin providers."""
return dict(_context_reference_providers)
_QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
REFERENCE_PATTERN = re.compile(
rf"(?<![\w/])@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|git|url):(?P<value>{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+))"
)
# Plugin fallback: any @<word>:<value> the built-in regex did not claim.
_PLUGIN_REFERENCE_PATTERN = re.compile(
rf"(?<![\w/])@(?P<kind>[a-zA-Z][a-zA-Z0-9_-]*):(?P<value>{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+)"
)
# ``@file:`` value: quoted path or bare path, each with an optional ``:start[-end]`` range.
_FILE_VALUE_PATTERN = re.compile(
r'^(?:(?P<quote>`|"|\')(?P<qpath>.+?)(?P=quote)|(?P<path>.+?))(?::(?P<start>\d+)(?:-(?P<end>\d+))?)?$'
)
TRAILING_PUNCTUATION = ",.;!?"
_OPENERS = {")": "(", "]": "[", "}": "{"}
_NEEDS_QUOTING = re.compile(r"""[\s()\[\]{}<>"'`]""")
_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh")
_SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",)
_SENSITIVE_HOME_FILES = tuple(Path(p) for p in (
".ssh/authorized_keys", ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/config", ".bashrc", ".zshrc",
".profile", ".bash_profile", ".zprofile", ".netrc", ".pgpass", ".npmrc", ".pypirc",
))
_TEXT_EXTENSIONS = (".py", ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".js", ".ts")
# Bound the work one message can force: each expanded ref reads at most a bounded prefix /
# window, and at most this many refs are expanded per message.
_MAX_EXPANDED_REFERENCES = 16
# Folder-listing line counts stop paying I/O past this size; bigger files report bytes only.
_LINE_COUNT_MAX_BYTES = 4 * 1024 * 1024
# Per-command stdout/stderr ceiling for the ``git``/``rg`` helpers; past it the child is
# killed and the nonzero returncode routes the caller to its fallback path.
_MAX_QUIET_OUTPUT_BYTES = 4 * 1024 * 1024
_OUTPUT_CAP_EXCEEDED_RETURNCODE = 137 # 128 + SIGKILL, the code a shell reports for a killed child
_FENCE_LANGUAGES = {
".py": "python", ".js": "javascript", ".ts": "typescript", ".tsx": "tsx", ".jsx": "jsx",
".json": "json", ".md": "markdown", ".sh": "bash", ".yml": "yaml", ".yaml": "yaml", ".toml": "toml",
}
@dataclass(frozen=True)
class ContextReference:
raw: str
kind: str
target: str
start: int
end: int
line_start: int | None = None
line_end: int | None = None
@dataclass
class ContextReferenceResult:
message: str
original_message: str
references: list[ContextReference] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
injected_tokens: int = 0
expanded: bool = False
blocked: bool = False
UrlFetcher = Callable[[str], str | Awaitable[str]] | None
Expansion = tuple[str | None, str | None] # (warning, block) — exactly one side is set
def format_reference_value(value: str) -> str:
"""Quote a value so ``REFERENCE_PATTERN`` (bare alternative ``\\S+``) reads it back whole.
Mirrors ``formatRefValue`` in the desktop's directive-text.tsx."""
if not _NEEDS_QUOTING.search(value):
return value
for quote in ("`", '"', "'"):
if quote not in value:
return f"{quote}{value}{quote}"
return value
def parse_context_references(message: str) -> list[ContextReference]:
refs: list[ContextReference] = []
if not message:
return refs
for match in REFERENCE_PATTERN.finditer(message):
kind = match.group("simple") or match.group("kind")
value = _strip_trailing_punctuation(match.group("value") or "")
if match.group("simple"):
target, line_start, line_end = "", None, None
elif kind == "file":
target, line_start, line_end = _parse_file_reference_value(value)
else:
target, line_start, line_end = _strip_reference_wrappers(value), None, None
refs.append(ContextReference(match.group(0), kind, target, match.start(), match.end(), line_start, line_end))
# Second pass: plugin-registered prefixes the built-in pattern missed.
for match in _PLUGIN_REFERENCE_PATTERN.finditer(message) if _context_reference_providers else ():
kind = match.group("kind")
if kind in BUILTIN_PREFIXES or kind not in _context_reference_providers:
continue
if any(r.kind == kind and r.start == match.start() for r in refs):
continue
target = _strip_reference_wrappers(_strip_trailing_punctuation(match.group("value") or ""))
refs.append(ContextReference(match.group(0), kind, target, match.start(), match.end()))
return refs
def preprocess_context_references(
message: str, *, cwd: str | Path, context_length: int, url_fetcher: UrlFetcher = None,
allowed_root: str | Path | None = None,
) -> ContextReferenceResult:
"""Sync wrapper; safe both without a loop (CLI) and inside a running loop (gateway)."""
coro = preprocess_context_references_async(
message, cwd=cwd, context_length=context_length, url_fetcher=url_fetcher, allowed_root=allowed_root
)
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
import concurrent.futures
import contextvars
# The side thread starts with an empty Context: without the caller's copy the served profile's
# HERMES_HOME override is lost and the credential-path guard checks the launch profile's .env.
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(contextvars.copy_context().run, asyncio.run, coro).result()
async def preprocess_context_references_async(
message: str, *, cwd: str | Path, context_length: int, url_fetcher: UrlFetcher = None,
allowed_root: str | Path | None = None,
) -> ContextReferenceResult:
refs = parse_context_references(message)
if not refs:
return ContextReferenceResult(message=message, original_message=message)
cwd_path = Path(cwd).expanduser().resolve()
# Default root = cwd so @ references cannot escape the workspace unless a caller widens it.
allowed_root_path = Path(allowed_root).expanduser().resolve() if allowed_root is not None else cwd_path
# Expand concurrently (each ref is independent; several @url: refs would otherwise
# serialize web_extract round-trips). gather preserves order, so warnings/blocks
# are assembled in ref order; the token-budget check runs once afterwards.
hard_limit = max(1, int(context_length * 0.50))
soft_limit = max(1, int(context_length * 0.25))
tasks = (
_expand_reference(ref, cwd_path, url_fetcher=url_fetcher, allowed_root=allowed_root_path,
max_inline_tokens=hard_limit)
for ref in refs[:_MAX_EXPANDED_REFERENCES]
)
expanded = await asyncio.gather(*tasks)
warnings = [warning for warning, _ in expanded if warning]
warnings.extend(
f"{ref.raw}: not expanded (maximum {_MAX_EXPANDED_REFERENCES} @-references per message)"
for ref in refs[_MAX_EXPANDED_REFERENCES:]
)
blocks = [block for _, block in expanded if block]
injected_tokens = sum(estimate_tokens_rough(block) for block in blocks)
result = ContextReferenceResult(
message=message, original_message=message, references=refs, warnings=warnings, injected_tokens=injected_tokens
)
if injected_tokens > hard_limit:
warnings.append(f"@ context injection refused: {injected_tokens} tokens exceeds the 50% hard limit ({hard_limit}).")
result.blocked = True
return result
if injected_tokens > soft_limit:
warnings.append(f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit}).")
# The `@file:`/`@folder:` tokens stay where the user typed them: the token IS the
# reference (clients render it as an inline chip); stripping it left a hole in the
# sentence and forced the desktop to re-derive refs from the attached block.
final = message
if warnings:
final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings)
if blocks:
final = f"{final}\n\n--- Attached Context ---\n\n" + "\n\n".join(blocks)
result.message = final.strip()
result.expanded = bool(blocks or warnings)
return result
# Git-backed reference kinds -> f(ref) -> git argv (the label is "git " + argv).
_GIT_REFERENCE_ARGS: dict[str, Callable[[ContextReference], list[str]]] = {
"diff": lambda ref: ["diff"],
"staged": lambda ref: ["diff", "--staged"],
"git": lambda ref: ["log", f"-{max(1, min(int(ref.target or '1'), 10))}", "-p"],
}
async def _expand_reference(
ref: ContextReference, cwd: Path, *, url_fetcher: UrlFetcher = None, allowed_root: Path | None = None,
max_inline_tokens: int | None = None,
) -> Expansion:
try:
if ref.kind in ("file", "folder"):
return _expand_path_reference(ref, cwd, allowed_root=allowed_root, max_inline_tokens=max_inline_tokens)
if ref.kind in _GIT_REFERENCE_ARGS:
git_args = _GIT_REFERENCE_ARGS[ref.kind](ref)
return _expand_git_reference(ref, cwd, git_args, "git " + " ".join(git_args))
if ref.kind == "url":
content = await _fetch_url_content(ref.target, url_fetcher=url_fetcher)
if not content:
return f"{ref.raw}: no content extracted", None
return None, f"🌐 {ref.raw} ({estimate_tokens_rough(content)} tokens)\n{content}"
except Exception as exc:
return f"{ref.raw}: {exc}", None
provider = _context_reference_providers.get(ref.kind)
if provider is not None:
try:
plugin_content = await provider.expand(ref.target)
if plugin_content is not None:
return None, f"📌 {ref.raw} ({estimate_tokens_rough(plugin_content)} tokens)\n{plugin_content}"
except Exception as exc:
return f"{ref.raw}: plugin expansion error: {exc}", None
return f"{ref.raw}: unsupported reference type", None
def _expand_path_reference(ref: ContextReference, cwd: Path, *, allowed_root: Path | None = None,
max_inline_tokens: int | None = None) -> Expansion:
"""``@file:`` / ``@folder:``: resolve, allow-check, then inline text / binary stub / listing."""
is_folder = ref.kind == "folder"
path = _resolve_path(cwd, ref.target, allowed_root=allowed_root)
_ensure_reference_path_allowed(path)
if not path.exists():
return f"{ref.raw}: {ref.kind} not found", None
if not (path.is_dir() if is_folder else path.is_file()):
return f"{ref.raw}: path is not a {ref.kind}", None
if is_folder:
listing = _build_folder_listing(path, cwd, display_base=allowed_root)
return None, f"📁 {ref.raw} ({estimate_tokens_rough(listing)} tokens)\n{listing}"
try:
# Keep admission through every sniff, stat and text read (a connection can start
# between a check and a later open otherwise), but release it before token
# counting/formatting: the registry lock blocks every tracked connect/close.
with offline_file_access(path, what="preview context reference"):
early, text = _read_file_reference(ref, path, max_inline_tokens)
except LiveConnectionError:
return None, _on_disk_reference_block(
ref, path, descriptor="live SQLite database file",
reason="not previewed: raw access would cancel SQLite's POSIX locks.",
guidance="Do not open this file directly while its database connection is live.",
)
return early or _format_file_reference(ref, path, text, max_inline_tokens)
def _read_file_reference(
ref: ContextReference, path: Path, max_inline_tokens: int | None,
) -> tuple[Expansion | None, str]:
"""Raw file I/O for an @file ref: ``(early, text)`` where ``early`` is a refusal block
(then ``text`` is empty) or ``None`` with the text to inline."""
if _is_binary_file(path):
# A bare "not supported" warning was a dead end (the model gave up); the file IS
# on disk where the agent's tools run, so hand it an actionable block instead.
return (None, _binary_reference_block(ref, path)), ""
if ref.line_start is not None:
# A ranged ref wants a slice, not the file: stream to the window so a GB-scale
# file serves :1-5 without being materialized. Lines are read in bounded pieces
# (budget + 1) so even a single-line giant (minified JSON, one-line logs) cannot
# force an unbounded string into memory; skipped lines are discarded, not kept.
char_budget = None if max_inline_tokens is None else max_inline_tokens * CHARS_PER_TOKEN
line_cap = None if char_budget is None else char_budget + 1
def _next_line(fh, collect: bool, remaining: int | None = None) -> str | None:
"""One line ("" when skipping), None at EOF. While collecting, stop as soon as the
pieces exceed ``remaining``: the caller returns the oversized block and never needs
the rest of a giant line, so it is never materialized."""
pieces, seen, collected = [], False, 0
while True:
piece = fh.readline() if line_cap is None else fh.readline(line_cap)
if not piece:
break
seen = True
if collect:
pieces.append(piece)
collected += len(piece)
if remaining is not None and collected > remaining:
break
if piece.endswith("\n"):
break
return ("".join(pieces) if collect else "") if seen else None
parts, total_chars = [], 0
with path.open(encoding="utf-8") as fh:
for _ in range(max(ref.line_start - 1, 0)):
if _next_line(fh, collect=False) is None:
break
for _ in range((ref.line_end or ref.line_start) - ref.line_start + 1):
line = _next_line(fh, collect=True, remaining=None if char_budget is None else char_budget - total_chars)
if line is None:
break
total_chars += len(line)
if char_budget is not None and total_chars > char_budget:
return (None, _oversized_text_reference_block(ref, path, total_chars // CHARS_PER_TOKEN)), ""
parts.append(line)
text = "".join(parts)
else:
# estimate_tokens_rough >= bytes/CHARS_PER_TOKEN for every encoding mix, so a
# file past that byte ceiling is certainly oversized; refuse without reading it.
size = path.stat().st_size
if max_inline_tokens is not None and size > max_inline_tokens * CHARS_PER_TOKEN:
return (None, _oversized_text_reference_block(ref, path, size // CHARS_PER_TOKEN)), ""
text = path.read_text(encoding="utf-8-sig")
return None, text
def _format_file_reference(ref: ContextReference, path: Path, text: str, max_inline_tokens: int | None) -> Expansion:
lang = _FENCE_LANGUAGES.get(path.suffix.lower(), "")
text_tokens = estimate_tokens_rough(text)
# Check BEFORE building the fenced block: an oversized file is not going to be
# inlined, so don't build a second MB-scale string just to discard it.
if max_inline_tokens is not None and text_tokens > max_inline_tokens:
# One oversized file used to poison the aggregate check and refuse the whole
# turn (#61987); the file stays readable via the agent's tools instead. The
# block alone carries the message (same shape as the binary path) — a warning
# would duplicate it in "--- Context Warnings ---".
return None, _oversized_text_reference_block(ref, path, text_tokens)
return None, f"📄 {ref.raw} ({text_tokens} tokens)\n```{lang}\n{text}\n```"
def _run_quiet(cmd: list[str], cwd: Path, timeout: int, env: dict | None = None) -> subprocess.CompletedProcess:
"""Captured text output with bounded pipes, no stdin, and no console flash on Windows.
``capture_output=True`` buffers the child's entire stdout — an unbounded ``git diff``
or ``rg --files`` would materialize a hostile-size stream in memory. Each pipe is
drained up to ``_MAX_QUIET_OUTPUT_BYTES``; past it the child is killed and the result
carries a nonzero returncode that routes callers to their existing fallback paths. Set
explicitly: a child that flushed everything and exited 0 before the drain thread crossed
the cap is not killable and would otherwise report success with truncated output.
"""
popen_kwargs: dict = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
proc = subprocess.Popen(
cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
**popen_kwargs, **({} if env is None else {"env": env}))
truncated = threading.Event()
def _drain(stream, sink: list[bytes]) -> None:
total = 0
while True:
chunk = stream.read1(1 << 16)
if not chunk:
return
total += len(chunk)
if total <= _MAX_QUIET_OUTPUT_BYTES:
sink.append(chunk)
else:
truncated.set()
proc.kill()
return
sinks: list[list[bytes]] = [[], []]
threads = [
threading.Thread(target=_drain, args=(stream, sink), daemon=True)
for stream, sink in zip((proc.stdout, proc.stderr), sinks)
]
for thread in threads:
thread.start()
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
raise subprocess.TimeoutExpired(cmd, timeout)
for thread in threads:
thread.join()
stdout, stderr = (b"".join(sink).decode("utf-8", "replace") for sink in sinks)
returncode = proc.returncode
if truncated.is_set() and returncode == 0:
returncode = _OUTPUT_CAP_EXCEEDED_RETURNCODE
return subprocess.CompletedProcess(cmd, returncode, stdout, stderr)
def _expand_git_reference(ref: ContextReference, cwd: Path, args: list[str], label: str) -> Expansion:
try:
# Repo-supplied config/attributes must never execute code (GHSA-7x36-8jrh-v4pw).
result = _run_quiet(["git", *harden_git_argv(args)], cwd, 30, env=noninteractive_git_env())
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
if result.returncode != 0:
return f"{ref.raw}: {(result.stderr or '').strip() or 'git command failed'}", None
content = result.stdout.strip() or "(no output)"
return None, f"🧾 {label} ({estimate_tokens_rough(content)} tokens)\n```diff\n{content}\n```"
async def _fetch_url_content(url: str, *, url_fetcher: UrlFetcher = None) -> str:
content = (url_fetcher or _default_url_fetcher)(url)
if inspect.isawaitable(content):
content = await content
return str(content or "").strip()
async def _default_url_fetcher(url: str) -> str:
from tools.web_tools import web_extract_tool
docs = json.loads(await web_extract_tool([url], format="markdown")).get("results", [])
return str(docs[0].get("content") or docs[0].get("raw_content") or "").strip() if docs else ""
def _is_under(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
# Desktop persists a large plain-text paste as a `.txt` under this Hermes-managed
# directory (apps/desktop/electron/composer-paste.ts) and attaches it as `@file:`.
# The chat's cwd is rarely an ancestor of it, so it is the one anchored root the
# workspace guard admits besides `allowed_root` itself.
COMPOSER_PASTES_DIRNAME = "composer-pastes"
def _composer_paste_roots() -> list[Path]:
from agent.file_safety import _hermes_dirs
return [hermes_dir / COMPOSER_PASTES_DIRNAME for hermes_dir in _hermes_dirs()]
def _agent_staged_path(path: Path) -> bool:
"""True when *path* sits in a Hermes dir the gateway stages for the agent.
Those are the ``_CACHE_DIRS`` roots — ``attachments/`` (file drops),
``images/`` (image uploads), ``cache/*`` (platform downloads) and now
``composer-pastes/`` (large text pastes) — the gateway's OWN payload, never
a workspace escape, and they live outside the workspace by construction: on
a remote execution backend (ssh and friends) the workspace root is a path
on THAT host (#110174), so the workspace check below rejected every staged
attachment there. The bytes are staged to (or already live on) the gateway
either way, so the ref still expands — text inlines; binaries point at the
backend-visible path via ``to_agent_visible_cache_path``.
"""
try:
from tools.credential_files import get_cache_directory_mounts
return any(_is_under(path, Path(entry["host_path"]).expanduser().resolve())
for entry in get_cache_directory_mounts())
except Exception:
return False
def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -> Path:
from agent.file_safety import is_nt_namespace_path
if is_nt_namespace_path(target): # raw-string check: resolving such a path is the NTLM-leak trigger
raise ValueError("path uses a Windows NT/device namespace prefix and cannot be attached")
resolved = (cwd / Path(os.path.expanduser(target))).resolve() # `/` keeps an absolute target as-is
if (
allowed_root is not None
and not _is_under(resolved, allowed_root)
and not any(_is_under(resolved, root) for root in _composer_paste_roots())
and not _agent_staged_path(resolved)
):
raise ValueError("path is outside the allowed workspace")
return resolved
def _ensure_reference_path_allowed(path: Path) -> None:
"""Refuse credential/internal paths. Fails CLOSED: the gateway feeds untrusted remote text here."""
from hermes_constants import get_hermes_home
home, hermes_home = Path(os.path.expanduser("~")).resolve(), get_hermes_home().resolve()
blocked_exact = {home / rel for rel in _SENSITIVE_HOME_FILES} | {hermes_home / ".env"}
blocked_dirs = [home / rel for rel in _SENSITIVE_HOME_DIRS] + [hermes_home / rel for rel in _SENSITIVE_HERMES_DIRS]
if path in blocked_exact:
raise ValueError("path is a sensitive credential file and cannot be attached")
if any(_is_under(path, blocked_dir) for blocked_dir in blocked_dirs):
raise ValueError("path is a sensitive credential or internal Hermes path and cannot be attached")
# Anchor to the canonical read deny-list (agent/file_safety.get_read_block_error): the
# narrow list above never caught auth.json, .anthropic_oauth.json, mcp-tokens/, webhook
# secrets or project .env files, and it grows automatically with that deny-list.
try:
from agent.file_safety import get_read_block_error
blocked = get_read_block_error(str(path)) is not None
except ValueError:
raise
except Exception:
# If the canonical lookup fails, falling through would re-open the exact hole this
# guard closes; a spurious block is recoverable, a leaked credential is not.
raise ValueError("path could not be verified against the credential deny-list and cannot be attached")
if blocked:
raise ValueError("path is a sensitive credential or internal Hermes path and cannot be attached")
def _strip_trailing_punctuation(value: str) -> str:
stripped = value.rstrip(TRAILING_PUNCTUATION)
# Drop unbalanced closers so "(see @file:x.py)" does not swallow the ")".
while stripped.endswith((")", "]", "}")) and stripped.count(stripped[-1]) > stripped.count(_OPENERS[stripped[-1]]):
stripped = stripped[:-1]
return stripped
def _strip_reference_wrappers(value: str) -> str:
return value[1:-1] if len(value) >= 2 and value[0] == value[-1] and value[0] in "`\"'" else value
def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None]:
m = _FILE_VALUE_PATTERN.match(value)
start = m and m.group("start")
if not start: # no line range: the whole value is the (possibly quoted) path
return _strip_reference_wrappers(value), None, None
return m.group("qpath") or m.group("path"), int(start), int(m.group("end") or start)
def _is_binary_file(path: Path) -> bool:
mime = mimetypes.guess_type(path.name)[0]
if mime and not mime.startswith("text/") and not path.name.endswith(_TEXT_EXTENSIONS):
return True
with path.open("rb") as fh: # sniff only; read_bytes() materialized the whole file
return b"\x00" in fh.read(4096)
def _build_folder_listing(path: Path, cwd: Path, limit: int = 200, display_base: Path | None = None) -> str:
# The target may sit outside cwd when the caller widened allowed_root: show it relative to
# cwd when possible, else relative to the allowed root, else the absolute path.
shown: str | None = None
for base in (cwd, display_base):
if base is None:
continue
try:
shown = f"{path.relative_to(base)}/"
break
except ValueError:
continue
if shown is None:
shown = f"{path}/"
lines = [shown]
entries = _iter_visible_entries(path, cwd, limit=limit)
for entry in entries:
indent = " " * max(len(entry.relative_to(path).parts) - 1, 0)
lines.append(f"{indent}- {entry.name}/" if entry.is_dir() else f"{indent}- {entry.name} ({_file_metadata(entry)})")
if len(entries) >= limit:
lines.append("- ...")
return "\n".join(lines)
def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
"""Files under ``path`` via ``rg --files`` (honours ignore files), else an os.walk fallback."""
try:
# Absolute path arg: rg echoes it as the output prefix, so results stay correct even
# when the folder is outside cwd (a widened allowed_root target).
rg = _run_quiet(["rg", "--files", str(path)], cwd, 10)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
rg = None
if rg is not None and rg.returncode == 0:
output: list[Path] = []
seen_dirs: set[Path] = set()
for line in [ln.strip() for ln in rg.stdout.splitlines() if ln.strip()][:limit]:
full = cwd / Path(line) # absolute lines pass through unchanged; defensive for relative
for parent in full.parents:
if parent in seen_dirs or path not in {parent, *parent.parents}:
continue
seen_dirs.add(parent)
output.append(parent)
output.append(full)
return sorted({p for p in output if p.exists()}, key=lambda p: (not p.is_dir(), str(p)))
output = []
for root, dirs, files in os.walk(path):
dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "__pycache__")
files = sorted(f for f in files if not f.startswith("."))
for name in dirs + files:
output.append(Path(root) / name)
if len(output) >= limit:
return output
return output
def _agent_visible_path(path: Path) -> str:
# Under a container backend the host path dangles inside the sandbox: translate staged
# files to their auto-mounted cache path; fall back to the host path (local backend /
# translation failure). Run the idempotent TERMINAL_ENV bridge first so in-process
# gateways that never bridged terminal.* config still see the active backend.
try:
from tools.terminal_tool import _ensure_terminal_env_bridged
_ensure_terminal_env_bridged()
from tools.credential_files import to_agent_visible_cache_path
return to_agent_visible_cache_path(str(path))
except Exception:
return str(path)
def _on_disk_reference_block(ref: ContextReference, path: Path, descriptor: str, reason: str, guidance: str) -> str:
"""Shared 📎 shape: the file was not inlined, but it IS on disk where the agent's
tools run — hand the model the path and a nudge instead of a dead-end warning."""
try:
size = format_bytes(path.stat().st_size)
except OSError:
size = "unknown size"
return (
f"📎 {ref.raw} ({descriptor}, {size}) — {reason} "
f"It is available on disk at `{_agent_visible_path(path)}`. {guidance}"
)
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
return _on_disk_reference_block(
ref, path,
descriptor=mime,
reason="binary file, not inlined as text.",
guidance="Use your tools to work with it (read or convert it, extract its text, "
"or view/render it as needed); do not tell the user the file type is unsupported.",
)
def _oversized_text_reference_block(ref: ContextReference, path: Path, text_tokens: int) -> str:
return _on_disk_reference_block(
ref, path,
descriptor=f"text file, approximately {text_tokens} tokens",
reason="too large to inline safely.",
guidance="Use read_file with a narrow line range, search_files, or terminal/code tools "
"to inspect only the relevant parts; do not load the entire file into context.",
)
def _file_metadata(path: Path) -> str:
try:
size = path.stat().st_size
except OSError:
return "unknown size"
try:
# A directory preview inspects each entry separately; the registry lock
# must cover both its binary sniff and optional line-count read.
with offline_file_access(path, what="inspect folder entry"):
# A listing line is a summary, not content: past the cap, byte size conveys
# the same "how big is this" without a full scan per entry.
if _is_binary_file(path) or size > _LINE_COUNT_MAX_BYTES:
return f"{size} bytes"
with path.open("rb") as fh:
# UTF-8 never embeds 0x0A inside a multibyte sequence, so counting bytes
# matches a decoded newline while streaming instead of read_text.
lines = sum(chunk.count(b"\n") for chunk in iter(lambda: fh.read(1 << 20), b""))
return f"{lines + 1} lines"
except (LiveConnectionError, OSError):
return f"{size} bytes"