|
7 | 7 | import json |
8 | 8 | import logging |
9 | 9 | import os |
| 10 | +import re |
10 | 11 | import threading |
11 | 12 | import contextvars |
12 | 13 | from collections import OrderedDict |
@@ -76,6 +77,95 @@ def _find_git_root(start: Path) -> Optional[Path]: |
76 | 77 |
|
77 | 78 |
|
78 | 79 | _HERMES_MD_NAMES = (".hermes.md", "HERMES.md") |
| 80 | +_AGENTS_MD_NAMES = ("AGENTS.md", "agents.md") |
| 81 | +_AGENTS_OVERRIDE_NAMES = ("AGENTS.override.md", "agents.override.md") |
| 82 | + |
| 83 | +# CLAUDE.md / AGENTS.md import directive: a line that is just ``@path`` pulls |
| 84 | +# in the referenced file (Claude Code / agents.md convention). |
| 85 | +_IMPORT_RE = re.compile(r"^[ \t]*@([^\s]+)[ \t]*$", re.MULTILINE) |
| 86 | + |
| 87 | + |
| 88 | +def _dirs_root_to_cwd(cwd_path: Path) -> list[Path]: |
| 89 | + """Directories from the git root (inclusive) down to *cwd*. |
| 90 | +
|
| 91 | + Ordered root-first, cwd-last, so concatenated content places deeper |
| 92 | + (closer-to-cwd) instructions later — letting the most specific file win |
| 93 | + on conflict. When *cwd* is not inside a git repo, only *cwd* itself is |
| 94 | + returned (we never walk the whole filesystem). |
| 95 | + """ |
| 96 | + cwd_path = cwd_path.resolve() |
| 97 | + stop_at = _find_git_root(cwd_path) |
| 98 | + if not stop_at: |
| 99 | + return [cwd_path] |
| 100 | + chain: list[Path] = [] |
| 101 | + for directory in [cwd_path, *cwd_path.parents]: |
| 102 | + chain.append(directory) |
| 103 | + if directory == stop_at: |
| 104 | + break |
| 105 | + return list(reversed(chain)) |
| 106 | + |
| 107 | + |
| 108 | +def _read_context_section(path: Path, label_base: Path) -> str: |
| 109 | + """Read one context file into a sanitized ``## <label>\\n\\n<body>`` block. |
| 110 | +
|
| 111 | + Returns an empty string when the file is missing, empty, or unreadable. |
| 112 | + The body is frontmatter-stripped and scanned for prompt injection |
| 113 | + before inclusion (a blocked file yields a safe ``[BLOCKED: ...]`` note). |
| 114 | + """ |
| 115 | + try: |
| 116 | + content = path.read_text(encoding="utf-8").strip() |
| 117 | + except Exception as e: |
| 118 | + logger.debug("Could not read %s: %s", path, e) |
| 119 | + return "" |
| 120 | + if not content: |
| 121 | + return "" |
| 122 | + content = _strip_yaml_frontmatter(content) |
| 123 | + try: |
| 124 | + label = str(path.relative_to(label_base)) |
| 125 | + except ValueError: |
| 126 | + label = path.name |
| 127 | + content = _scan_context_content(content, label) |
| 128 | + return f"## {label}\n\n{content}" |
| 129 | + |
| 130 | + |
| 131 | +def _resolve_claude_imports(content: str, base_dir: Path) -> str: |
| 132 | + """Inline single-level ``@path`` imports inside CLAUDE.md. |
| 133 | +
|
| 134 | + Each ``@relative/path.md`` line is replaced by the imported file's |
| 135 | + sanitized body. Imports resolve relative to *base_dir* and must stay |
| 136 | + within it (no ``../`` traversal escapes). Only one level is resolved — |
| 137 | + ``@imports`` inside imported files are left literal — to avoid cycles |
| 138 | + and unbounded expansion. |
| 139 | + """ |
| 140 | + base_resolved = base_dir.resolve() |
| 141 | + |
| 142 | + def _replace(match: "re.Match[str]") -> str: |
| 143 | + rel = match.group(1) |
| 144 | + target = (base_dir / rel).resolve() |
| 145 | + try: |
| 146 | + target.relative_to(base_resolved) |
| 147 | + except ValueError: |
| 148 | + logger.debug("Ignoring CLAUDE.md import outside base dir: %s", rel) |
| 149 | + return match.group(0) |
| 150 | + if not target.is_file(): |
| 151 | + logger.debug("CLAUDE.md import not found: %s", target) |
| 152 | + return match.group(0) |
| 153 | + try: |
| 154 | + imported = target.read_text(encoding="utf-8").strip() |
| 155 | + except Exception as e: |
| 156 | + logger.debug("Could not read CLAUDE.md import %s: %s", target, e) |
| 157 | + return match.group(0) |
| 158 | + if not imported: |
| 159 | + return match.group(0) |
| 160 | + imported = _strip_yaml_frontmatter(imported) |
| 161 | + imported = _scan_context_content(imported, rel) |
| 162 | + # Markdown blockquote marker (NOT an HTML comment): the assembled |
| 163 | + # CLAUDE.md is re-scanned for injection, and an "<!-- ... -->" marker |
| 164 | + # would false-positive on html_comment_injection when the import path |
| 165 | + # contains a flagged keyword (e.g. @config/system.md). |
| 166 | + return f"> imported from {rel}\n{imported}" |
| 167 | + |
| 168 | + return _IMPORT_RE.sub(_replace, content) |
79 | 169 |
|
80 | 170 |
|
81 | 171 | def _find_hermes_md(cwd: Path) -> Optional[Path]: |
@@ -1790,32 +1880,70 @@ def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str |
1790 | 1880 |
|
1791 | 1881 |
|
1792 | 1882 | def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str: |
1793 | | - """AGENTS.md — top-level only (no recursive walk).""" |
1794 | | - for name in ["AGENTS.md", "agents.md"]: |
1795 | | - candidate = cwd_path / name |
1796 | | - if candidate.exists(): |
1797 | | - try: |
1798 | | - content = candidate.read_text(encoding="utf-8").strip() |
1799 | | - if content: |
1800 | | - content = _scan_context_content(content, name) |
1801 | | - result = f"## {name}\n\n{content}" |
1802 | | - return _truncate_content( |
1803 | | - result, "AGENTS.md", context_length=context_length, |
1804 | | - read_path=str(candidate), |
1805 | | - ) |
1806 | | - except Exception as e: |
1807 | | - logger.debug("Could not read %s: %s", candidate, e) |
1808 | | - return "" |
| 1883 | + """AGENTS.md per the AGENTS.md standard (nested walk + overrides). |
| 1884 | +
|
| 1885 | + Walks from the git root down to *cwd*, collecting every ``AGENTS.md`` |
| 1886 | + (root-first, so the nearest file's instructions come last and win on |
| 1887 | + conflict). ``AGENTS.override.md`` files are gathered separately and |
| 1888 | + appended after all ``AGENTS.md`` content, giving them the highest |
| 1889 | + precedence. Each file is scanned for prompt injection individually; the |
| 1890 | + merged blob is size-capped as a whole. The set of loaded files is logged |
| 1891 | + at debug level so users can verify which rules were applied. |
| 1892 | + """ |
| 1893 | + git_root = _find_git_root(cwd_path) or cwd_path |
| 1894 | + base: list[str] = [] |
| 1895 | + overrides: list[str] = [] |
| 1896 | + loaded_paths: list[Path] = [] |
| 1897 | + |
| 1898 | + for directory in _dirs_root_to_cwd(cwd_path): |
| 1899 | + for name in _AGENTS_MD_NAMES: |
| 1900 | + candidate = directory / name |
| 1901 | + if candidate.is_file(): |
| 1902 | + section = _read_context_section(candidate, git_root) |
| 1903 | + if section: |
| 1904 | + base.append(section) |
| 1905 | + loaded_paths.append(candidate) |
| 1906 | + break # one AGENTS.md per directory |
| 1907 | + for name in _AGENTS_OVERRIDE_NAMES: |
| 1908 | + candidate = directory / name |
| 1909 | + if candidate.is_file(): |
| 1910 | + section = _read_context_section(candidate, git_root) |
| 1911 | + if section: |
| 1912 | + overrides.append(section) |
| 1913 | + loaded_paths.append(candidate) |
| 1914 | + break |
| 1915 | + |
| 1916 | + if not base and not overrides: |
| 1917 | + return "" |
| 1918 | + |
| 1919 | + logger.debug( |
| 1920 | + "AGENTS.md context loaded from: %s", |
| 1921 | + ", ".join(str(p) for p in loaded_paths), |
| 1922 | + ) |
| 1923 | + merged = "\n\n".join([*base, *overrides]) |
| 1924 | + return _truncate_content( |
| 1925 | + merged, "AGENTS.md", context_length=context_length, |
| 1926 | + read_path=str(loaded_paths[-1]) if loaded_paths else "AGENTS.md", |
| 1927 | + ) |
1809 | 1928 |
|
1810 | 1929 |
|
1811 | 1930 | def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str: |
1812 | | - """CLAUDE.md / claude.md — cwd only.""" |
| 1931 | + """CLAUDE.md / claude.md — cwd only, with ``@path`` imports resolved.""" |
1813 | 1932 | for name in ["CLAUDE.md", "claude.md"]: |
1814 | 1933 | candidate = cwd_path / name |
1815 | 1934 | if candidate.exists(): |
1816 | 1935 | try: |
1817 | 1936 | content = candidate.read_text(encoding="utf-8").strip() |
1818 | 1937 | if content: |
| 1938 | + # Inline @path imports (each imported file is also scanned |
| 1939 | + # individually inside _resolve_claude_imports), THEN scan |
| 1940 | + # the assembled blob. The full re-scan is deliberate: it |
| 1941 | + # catches an injection split across the import/body seam |
| 1942 | + # that per-fragment scanning alone would miss. The import |
| 1943 | + # marker is a markdown blockquote (not an HTML comment) so |
| 1944 | + # the re-scan doesn't false-positive on keyword import |
| 1945 | + # paths like @config/system.md. |
| 1946 | + content = _resolve_claude_imports(content, cwd_path) |
1819 | 1947 | content = _scan_context_content(content, name) |
1820 | 1948 | result = f"## {name}\n\n{content}" |
1821 | 1949 | return _truncate_content( |
@@ -1869,8 +1997,8 @@ def build_context_files_prompt( |
1869 | 1997 |
|
1870 | 1998 | Priority (first found wins — only ONE project context type is loaded): |
1871 | 1999 | 1. .hermes.md / HERMES.md (walk to git root) |
1872 | | - 2. AGENTS.md / agents.md (cwd only) |
1873 | | - 3. CLAUDE.md / claude.md (cwd only) |
| 2000 | + 2. AGENTS.md / agents.md (nested walk git-root→cwd, + AGENTS.override.md) |
| 2001 | + 3. CLAUDE.md / claude.md (cwd only, with @path imports resolved) |
1874 | 2002 | 4. .cursorrules / .cursor/rules/*.mdc (cwd only) |
1875 | 2003 |
|
1876 | 2004 | SOUL.md from HERMES_HOME is independent and always included when present. |
|
0 commit comments