Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ RUN apt-get update && \

# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
RUN chown -R hermes:hermes /opt/data

COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
Expand Down
274 changes: 274 additions & 0 deletions agent/context_includes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
"""@-include expansion for context files (CAAMP-style transitive includes).

This module is the single source of truth for how Hermes resolves
``@<path>`` directives inside context files (``.hermes.md``, ``AGENTS.md``,
``CLAUDE.md``, ``.cursorrules``, ``SOUL.md``, ``.cursor/rules/*.mdc``).

Design goals
============

* **Provider-neutral** — works the same way Claude Code, Cursor, and other
agent harnesses treat ``@`` references, so a project's instruction files
behave consistently across runtimes.
* **Bounded** — every expansion has a hard depth cap and a per-include
size cap so a runaway chain or a single huge file cannot blow out the
system prompt.
* **Cycle-safe** — file inclusion graphs may form cycles
(A → B → A) and we must terminate without infinite recursion.
* **Injection-safe** — every expanded chunk is run through the same
``_scan_context_content`` pipeline as a top-level context file so
malicious payloads in an included file are blocked before they reach
the model.
* **Inert in code blocks** — ``@<path>`` written inside a fenced code
block (```` ``` ```` or ``~~~``) is left as literal text so docs that
*describe* the syntax don't accidentally trigger expansion.
* **Pure / testable** — the module exposes a small surface
(:func:`expand_includes`) that callers can unit-test in isolation.

Public API
==========

* :func:`expand_includes` — recursive expander. Single entry point
consumers should call.
* :data:`CONTEXT_INCLUDE_MAX_DEPTH` — depth cap (default ``5``).
* :data:`INCLUDE_PATTERN` — the regex used to detect include directives.

Callers in :mod:`agent.prompt_builder` inject a ``scanner`` and a
``truncator`` so this module stays free of upstream-specific behavior:
the scanner is the prompt-injection guard and the truncator is the
head/tail size limiter. Both default to identity functions when not
supplied — useful for tests and for callers that don't need either pass.
"""

from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import Callable, Optional

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Public constants
# ---------------------------------------------------------------------------

# Maximum recursion depth for nested @-includes. Includes beyond this depth
# are replaced with a "<!-- @max-depth -->" marker so the prompt can never
# blow up from a runaway chain or accidental recursion.
CONTEXT_INCLUDE_MAX_DEPTH = 5

# Pattern: a line that contains ONLY an @<path> reference (with optional
# leading/trailing whitespace). This intentionally does NOT match inline
# @mentions in prose (e.g. "see @bob") to avoid false positives with email
# addresses, social handles, or doc references.
#
# Allowed path characters cover absolute paths, relative paths, ~, env
# vars (${VAR}), and common punctuation in filenames. We deliberately
# exclude whitespace and shell metacharacters.
INCLUDE_PATTERN = re.compile(
r"^[ \t]*@([\w./~$\-+:{}]+)[ \t]*$",
re.MULTILINE,
)

# Sentinel format used to swap out fenced code blocks during expansion so
# their contents are not scanned for @-includes. The NUL byte makes
# accidental collisions with real content impossible.
_FENCE_SENTINEL = "\x00CONTEXT_INCLUDE_FENCE{}\x00"
_FENCE_PATTERN = re.compile(r"(?ms)^([ \t]*)(```|~~~)[^\n]*\n.*?^\1\2[ \t]*$")


# ---------------------------------------------------------------------------
# Type aliases for the injectable hooks
# ---------------------------------------------------------------------------

# scanner(content, label) -> sanitized_content
ContentScanner = Callable[[str, str], str]
# truncator(content, label) -> possibly-truncated_content
ContentTruncator = Callable[[str, str], str]


def _identity_scanner(content: str, _label: str) -> str:
return content


def _identity_truncator(content: str, _label: str) -> str:
return content


# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------


def resolve_include_path(raw: str, base_dir: Path) -> Path:
"""Resolve an @-include path: env vars, ~, then relative to *base_dir*.

Absolute paths (after ~ / env-var expansion) are kept absolute.
Relative paths resolve against the *including* file's directory so
nested includes behave intuitively when files move.
"""
expanded = os.path.expandvars(os.path.expanduser(raw))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lets a repository-controlled context file resolve ~, environment variables, and arbitrary absolute paths before the expander reads the target into the model prompt. That bypasses the existing workspace-containment policy in agent/subdirectory_hints.py:172-195; please restrict this to an explicit trusted root or user-approved external source and add a rejection test.

candidate = Path(expanded)
if not candidate.is_absolute():
candidate = base_dir / candidate
try:
return candidate.resolve()
except (OSError, RuntimeError):
return candidate


# ---------------------------------------------------------------------------
# Code-fence masking (so @ inside ``` blocks is inert)
# ---------------------------------------------------------------------------


def _mask_code_fences(content: str) -> tuple[str, list[tuple[int, str]]]:
"""Replace fenced code blocks with sentinel placeholders.

Returns ``(masked_content, fences)`` where ``fences`` lets callers
restore the original blocks via :func:`_unmask_code_fences`.
"""
fences: list[tuple[int, str]] = []

def _replace(match: "re.Match[str]") -> str:
idx = len(fences)
fences.append((idx, match.group(0)))
return _FENCE_SENTINEL.format(idx)

return _FENCE_PATTERN.sub(_replace, content), fences


def _unmask_code_fences(content: str, fences: list[tuple[int, str]]) -> str:
"""Inverse of :func:`_mask_code_fences`."""
for idx, original in fences:
content = content.replace(_FENCE_SENTINEL.format(idx), original)
return content


# ---------------------------------------------------------------------------
# Recursive expander
# ---------------------------------------------------------------------------


def expand_includes(
content: str,
base_dir: Path,
*,
depth: int = 0,
visited: Optional[set[str]] = None,
scanner: ContentScanner = _identity_scanner,
truncator: ContentTruncator = _identity_truncator,
max_depth: int = CONTEXT_INCLUDE_MAX_DEPTH,
) -> str:
"""Recursively expand ``@<path>`` directives in *content*.

Parameters
----------
content
The text to scan. Typically the body of a context file
(``AGENTS.md``, ``.hermes.md``, etc.).
base_dir
Directory used to resolve *relative* include paths. Should be
the directory of the file *content* came from.
depth
Current recursion depth. Callers should leave this at ``0``;
the function increments it on recursive calls.
visited
Set of resolved absolute paths already on the include stack.
Used for cycle detection. Callers should leave this ``None``;
the function seeds it on the first call.
scanner
Optional content-sanitization hook applied to every expanded
chunk. Defaults to a no-op. Hermes injects its
``_scan_context_content`` function here so prompt-injection
attempts in included files are caught.
truncator
Optional size-limiting hook applied to every expanded chunk.
Defaults to a no-op. Hermes injects its ``_truncate_content``
function here so a single huge include cannot dominate the
prompt.
max_depth
Hard cap on recursion depth. Includes beyond this depth are
replaced with a ``<!-- @max-depth -->`` marker.

Returns
-------
str
The expanded content with each ``@<path>`` directive replaced
by the wrapped contents of the target file.

Notes
-----
Markers emitted into the output:

* ``<!-- @include-begin: <raw> -->`` … ``<!-- @include-end: <raw> -->``
surround successfully expanded chunks.
* ``<!-- @missing: <raw> ... -->`` when the target file does not exist.
* ``<!-- @cycle: already-included <raw> -->`` when the include would
reintroduce a file already on the stack.
* ``<!-- @max-depth: <raw> ... -->`` when the depth cap is hit.
* ``<!-- @unreadable: <raw> (<error>) -->`` for I/O errors.
"""
if visited is None:
visited = set()

if depth >= max_depth:
# Strip include directives at the depth boundary so they don't
# appear as raw "@..." text in the rendered prompt.
masked, fences = _mask_code_fences(content)
masked = INCLUDE_PATTERN.sub(
lambda m: f"<!-- @max-depth: {m.group(1)} (max_depth={max_depth}) -->",
masked,
)
return _unmask_code_fences(masked, fences)

masked, fences = _mask_code_fences(content)

def _expand_one(match: "re.Match[str]") -> str:
raw = match.group(1)
target = resolve_include_path(raw, base_dir)
target_str = str(target)

if not target.exists() or not target.is_file():
return f"<!-- @missing: {raw} (resolved: {target_str}, file not found) -->"

if target_str in visited:
return f"<!-- @cycle: already-included {raw} -->"

try:
included_text = target.read_text(encoding="utf-8")
except OSError as exc:
logger.debug("Could not read @-include %s: %s", target, exc)
return f"<!-- @unreadable: {raw} ({exc}) -->"

# Mark visited BEFORE recursing so cycles A→B→A terminate.
new_visited = visited | {target_str}
expanded = expand_includes(
included_text,
target.parent,
depth=depth + 1,
visited=new_visited,
scanner=scanner,
truncator=truncator,
max_depth=max_depth,
)
scanned = scanner(expanded, raw)
capped = truncator(scanned, raw)
return (
f"<!-- @include-begin: {raw} -->\n"
f"{capped}\n"
f"<!-- @include-end: {raw} -->"
)

expanded = INCLUDE_PATTERN.sub(_expand_one, masked)
return _unmask_code_fences(expanded, fences)


__all__ = [
"CONTEXT_INCLUDE_MAX_DEPTH",
"INCLUDE_PATTERN",
"expand_includes",
"resolve_include_path",
]
30 changes: 30 additions & 0 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,14 @@ def build_environment_hints() -> str:
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2

# @-include expansion is implemented in agent.context_includes. Re-export
# the public knobs here so callers and tests have a single import surface
# (``from agent.prompt_builder import CONTEXT_INCLUDE_MAX_DEPTH``).
from agent.context_includes import ( # noqa: E402
CONTEXT_INCLUDE_MAX_DEPTH,
expand_includes as _expand_includes_raw,
)


# =========================================================================
# Skills prompt cache
Expand Down Expand Up @@ -1019,6 +1027,22 @@ def _status_line(feature) -> str:
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================


def _expand_includes(content: str, base_dir: Path) -> str:
"""Project-aware wrapper around :func:`agent.context_includes.expand_includes`.

Wires in :func:`_scan_context_content` (prompt-injection guard) and
:func:`_truncate_content` (size cap) so every included chunk is held
to the same standards as a top-level context file.
"""
return _expand_includes_raw(
content,
base_dir,
scanner=_scan_context_content,
truncator=_truncate_content,
)


def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
"""Head/tail truncation with a marker in the middle."""
if len(content) <= max_chars:
Expand Down Expand Up @@ -1051,6 +1075,7 @@ def load_soul_md() -> Optional[str]:
content = soul_path.read_text(encoding="utf-8").strip()
if not content:
return None
content = _expand_includes(content, soul_path.parent)
content = _scan_context_content(content, "SOUL.md")
content = _truncate_content(content, "SOUL.md")
return content
Expand All @@ -1074,6 +1099,7 @@ def _load_hermes_md(cwd_path: Path) -> str:
rel = str(hermes_md_path.relative_to(cwd_path))
except ValueError:
pass
content = _expand_includes(content, hermes_md_path.parent)
content = _scan_context_content(content, rel)
result = f"## {rel}\n\n{content}"
return _truncate_content(result, ".hermes.md")
Expand All @@ -1090,6 +1116,7 @@ def _load_agents_md(cwd_path: Path) -> str:
try:
content = candidate.read_text(encoding="utf-8").strip()
if content:
content = _expand_includes(content, candidate.parent)
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(result, "AGENTS.md")
Expand All @@ -1106,6 +1133,7 @@ def _load_claude_md(cwd_path: Path) -> str:
try:
content = candidate.read_text(encoding="utf-8").strip()
if content:
content = _expand_includes(content, candidate.parent)
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(result, "CLAUDE.md")
Expand All @@ -1122,6 +1150,7 @@ def _load_cursorrules(cwd_path: Path) -> str:
try:
content = cursorrules_file.read_text(encoding="utf-8").strip()
if content:
content = _expand_includes(content, cursorrules_file.parent)
content = _scan_context_content(content, ".cursorrules")
cursorrules_content += f"## .cursorrules\n\n{content}\n\n"
except Exception as e:
Expand All @@ -1134,6 +1163,7 @@ def _load_cursorrules(cwd_path: Path) -> str:
try:
content = mdc_file.read_text(encoding="utf-8").strip()
if content:
content = _expand_includes(content, mdc_file.parent)
content = _scan_context_content(content, f".cursor/rules/{mdc_file.name}")
cursorrules_content += f"## .cursor/rules/{mdc_file.name}\n\n{content}\n\n"
except Exception as e:
Expand Down
9 changes: 9 additions & 0 deletions agent/subdirectory_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pathlib import Path
from typing import Dict, Any, Optional, Set

from agent.context_includes import expand_includes
from agent.prompt_builder import _scan_context_content

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -184,6 +185,14 @@ def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
content = hint_path.read_text(encoding="utf-8").strip()
if not content:
continue
# Expand @<path> includes so subdirectory hints honor the
# same single-source-of-truth pattern as startup context
# files. See agent.context_includes for semantics.
content = expand_includes(
content,
hint_path.parent,
scanner=_scan_context_content,
)
# Same security scan as startup context loading
content = _scan_context_content(content, filename)
if len(content) > _MAX_HINT_CHARS:
Expand Down
1 change: 1 addition & 0 deletions docker/entrypoint.sh
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ if [ "$(id -u)" = "0" ]; then
# by the mapped user on the host side.
chown -R hermes:hermes "$HERMES_HOME" 2>/dev/null || \
echo "Warning: chown failed (rootless container?) — continuing anyway"
chmod 755 "$HERMES_HOME" 2>/dev/null || true
fi

# Ensure config.yaml is readable by the hermes runtime user even if it was
Expand Down
Loading