Skip to content

Commit fd2859d

Browse files
chambridgeclaude
authored andcommitted
feat(assessors): support AGENTS.md and @ references in CLAUDEmdAssessor (#265)
Add comprehensive support for cross-tool compatibility and flexible configuration: - Support CLAUDE.md as symlink to AGENTS.md - Support @ reference syntax (@AGENTS.md) for content indirection - Accept AGENTS.md as alternative to CLAUDE.md (90/100 score) - Detect cross-tool compatibility when both files present - Implement path traversal security protection (reject ../ and absolute paths) - Add 14 comprehensive unit tests including security validation - Add type hints and performance improvements This enhancement enables repositories to: 1. Share configuration across multiple AI coding assistants 2. Use flexible @ reference indirection for minimal CLAUDE.md files 3. Maintain backward compatibility with existing CLAUDE.md workflows Closes #244 🤖 Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Chris Hambridge <chambrid@redhat.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 37f36e6 commit fd2859d

File tree

2 files changed

+661
-24
lines changed

2 files changed

+661
-24
lines changed

src/agentready/assessors/documentation.py

Lines changed: 220 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import ast
44
import json
55
import re
6+
from pathlib import Path
67

78
import yaml
89

@@ -43,49 +44,149 @@ def attribute(self) -> Attribute:
4344
def assess(self, repository: Repository) -> Finding:
4445
"""Check for CLAUDE.md file in repository root.
4546
46-
Pass criteria: CLAUDE.md exists
47-
Scoring: Binary (100 if exists, 0 if not)
47+
Pass criteria:
48+
- CLAUDE.md exists with >50 bytes, OR
49+
- CLAUDE.md is a symlink to a file with >50 bytes, OR
50+
- CLAUDE.md contains @ reference to a file with >50 bytes, OR
51+
- AGENTS.md exists with >50 bytes (alternative)
52+
53+
Security:
54+
- @ references are restricted to relative paths within repository
55+
- Path traversal attempts (../) and absolute paths are rejected
56+
57+
Scoring:
58+
- 100 if CLAUDE.md passes (direct, symlink, or @ reference)
59+
- 90 if AGENTS.md exists without CLAUDE.md
60+
- 25 if CLAUDE.md exists but is minimal without valid references
61+
- 0 if neither file exists
4862
"""
4963
claude_md_path = repository.path / "CLAUDE.md"
64+
agents_md_path = repository.path / "AGENTS.md"
5065

51-
# Fix TOCTOU: Use try-except around file read instead of existence check
66+
# Check for CLAUDE.md first
5267
try:
53-
with open(claude_md_path, "r", encoding="utf-8") as f:
68+
# Resolve symlinks if CLAUDE.md is a symlink
69+
resolved_path = claude_md_path.resolve(strict=True)
70+
is_symlink = claude_md_path.is_symlink()
71+
72+
with open(resolved_path, "r", encoding="utf-8") as f:
5473
content = f.read()
5574

5675
size = len(content)
57-
if size < 50:
58-
# File exists but is too small
76+
77+
# Check if file has sufficient content
78+
if size >= 50:
79+
evidence = [f"CLAUDE.md found at {claude_md_path}"]
80+
if is_symlink:
81+
target = (
82+
resolved_path.relative_to(repository.path)
83+
if resolved_path.is_relative_to(repository.path)
84+
else resolved_path
85+
)
86+
evidence.append(f"Symlink to {target} ({size} bytes)")
87+
88+
# Bonus: Check if AGENTS.md also exists
89+
if self._check_agents_md_exists(agents_md_path):
90+
evidence.append("AGENTS.md also present (cross-tool compatibility)")
91+
5992
return Finding(
6093
attribute=self.attribute,
61-
status="fail",
62-
score=25.0,
63-
measured_value=f"{size} bytes",
64-
threshold=">50 bytes",
65-
evidence=[f"CLAUDE.md exists but is minimal ({size} bytes)"],
66-
remediation=self._create_remediation(),
94+
status="pass",
95+
score=100.0,
96+
measured_value="present",
97+
threshold="present",
98+
evidence=evidence,
99+
remediation=None,
67100
error_message=None,
68101
)
69102

103+
# File is small - check for @ references
104+
referenced_file = self._extract_at_reference(content)
105+
if referenced_file:
106+
ref_path = repository.path / referenced_file
107+
ref_content, ref_size = self._read_referenced_file(ref_path)
108+
109+
if ref_content and ref_size >= 50:
110+
evidence = [
111+
f"CLAUDE.md found with @ reference to {referenced_file}",
112+
f"Referenced file contains {ref_size} bytes",
113+
]
114+
115+
# Bonus: Check if AGENTS.md also exists
116+
if self._check_agents_md_exists(agents_md_path):
117+
evidence.append(
118+
"AGENTS.md also present (cross-tool compatibility)"
119+
)
120+
121+
return Finding(
122+
attribute=self.attribute,
123+
status="pass",
124+
score=100.0,
125+
measured_value=f"@ reference to {referenced_file}",
126+
threshold="present",
127+
evidence=evidence,
128+
remediation=None,
129+
error_message=None,
130+
)
131+
else:
132+
# Referenced file doesn't exist or is too small
133+
return Finding(
134+
attribute=self.attribute,
135+
status="fail",
136+
score=25.0,
137+
measured_value=f"{size} bytes, invalid @ reference",
138+
threshold=">50 bytes or valid @ reference",
139+
evidence=[
140+
f"CLAUDE.md exists but is minimal ({size} bytes)",
141+
f"@ reference to {referenced_file} but file is missing or too small",
142+
],
143+
remediation=self._create_remediation(),
144+
error_message=None,
145+
)
146+
147+
# File is small and no valid @ reference
70148
return Finding(
71149
attribute=self.attribute,
72-
status="pass",
73-
score=100.0,
74-
measured_value="present",
75-
threshold="present",
76-
evidence=[f"CLAUDE.md found at {claude_md_path}"],
77-
remediation=None,
150+
status="fail",
151+
score=25.0,
152+
measured_value=f"{size} bytes",
153+
threshold=">50 bytes",
154+
evidence=[f"CLAUDE.md exists but is minimal ({size} bytes)"],
155+
remediation=self._create_remediation(),
78156
error_message=None,
79157
)
80158

81159
except FileNotFoundError:
160+
# CLAUDE.md not found - check for AGENTS.md as alternative
161+
agents_content, agents_size = self._read_referenced_file(agents_md_path)
162+
163+
if agents_content and agents_size >= 50:
164+
return Finding(
165+
attribute=self.attribute,
166+
status="pass",
167+
score=90.0, # Slightly lower score for missing CLAUDE.md
168+
measured_value="AGENTS.md present",
169+
threshold="CLAUDE.md or AGENTS.md",
170+
evidence=[
171+
"CLAUDE.md not found",
172+
f"AGENTS.md found with {agents_size} bytes (alternative)",
173+
"Consider adding CLAUDE.md as symlink or @ reference for broader tool support",
174+
],
175+
remediation=None,
176+
error_message=None,
177+
)
178+
179+
# Neither file exists
82180
return Finding(
83181
attribute=self.attribute,
84182
status="fail",
85183
score=0.0,
86184
measured_value="missing",
87185
threshold="present",
88-
evidence=["CLAUDE.md not found in repository root"],
186+
evidence=[
187+
"CLAUDE.md not found in repository root",
188+
"AGENTS.md not found (alternative)",
189+
],
89190
remediation=self._create_remediation(),
90191
error_message=None,
91192
)
@@ -94,12 +195,57 @@ def assess(self, repository: Repository) -> Finding:
94195
self.attribute, reason=f"Could not read CLAUDE.md file: {e}"
95196
)
96197

198+
def _extract_at_reference(self, content: str) -> str | None:
199+
"""Extract @ reference from CLAUDE.md content.
200+
201+
Looks for patterns like:
202+
- @AGENTS.md
203+
- @.claude/agents.md
204+
- @ AGENTS.md (with space)
205+
206+
Security: Rejects path traversal attempts (../) and absolute paths.
207+
208+
Returns the referenced filename or None if no reference found.
209+
"""
210+
# Match @filename.md or @ filename.md (with optional space)
211+
# Support paths like @.claude/agents.md
212+
pattern = r"@\s*([A-Za-z0-9_\-./]+\.md)"
213+
match = re.search(pattern, content, re.IGNORECASE)
214+
215+
if match:
216+
ref = match.group(1)
217+
# Prevent path traversal - reject if contains .. or starts with /
218+
if ".." in ref or ref.startswith("/"):
219+
return None
220+
return ref
221+
return None
222+
223+
def _read_referenced_file(self, file_path: Path) -> tuple[str | None, int]:
224+
"""Read a referenced file and return its content and size.
225+
226+
Returns (content, size) tuple, or (None, 0) if file doesn't exist or can't be read.
227+
"""
228+
try:
229+
with open(file_path, "r", encoding="utf-8") as f:
230+
content = f.read()
231+
return content, len(content)
232+
except (FileNotFoundError, OSError, UnicodeDecodeError):
233+
return None, 0
234+
235+
def _check_agents_md_exists(self, agents_md_path: Path) -> bool:
236+
"""Check if AGENTS.md exists and has sufficient content."""
237+
content, size = self._read_referenced_file(agents_md_path)
238+
return content is not None and size >= 50
239+
97240
def _create_remediation(self) -> Remediation:
98241
"""Create remediation guidance for missing/inadequate CLAUDE.md."""
99242
return Remediation(
100-
summary="Create CLAUDE.md file with project-specific configuration for Claude Code",
243+
summary="Create CLAUDE.md or AGENTS.md with project-specific configuration for AI coding assistants",
101244
steps=[
102-
"Create CLAUDE.md file in repository root",
245+
"Choose one of three approaches:",
246+
" Option 1: Create standalone CLAUDE.md (>50 bytes) with project context",
247+
" Option 2: Create AGENTS.md and symlink CLAUDE.md to it (cross-tool compatibility)",
248+
" Option 3: Create AGENTS.md and reference it with @AGENTS.md in minimal CLAUDE.md",
103249
"Add project overview and purpose",
104250
"Document key architectural patterns",
105251
"Specify coding standards and conventions",
@@ -108,11 +254,22 @@ def _create_remediation(self) -> Remediation:
108254
],
109255
tools=[],
110256
commands=[
257+
"# Option 1: Standalone CLAUDE.md",
111258
"touch CLAUDE.md",
112259
"# Add content describing your project",
260+
"",
261+
"# Option 2: Symlink CLAUDE.md to AGENTS.md",
262+
"touch AGENTS.md",
263+
"# Add content to AGENTS.md",
264+
"ln -s AGENTS.md CLAUDE.md",
265+
"",
266+
"# Option 3: @ reference in CLAUDE.md",
267+
"echo '@AGENTS.md' > CLAUDE.md",
268+
"touch AGENTS.md",
269+
"# Add content to AGENTS.md",
113270
],
114271
examples=[
115-
"""# My Project
272+
"""# Standalone CLAUDE.md (Option 1)
116273
117274
## Overview
118275
Brief description of what this project does.
@@ -136,15 +293,54 @@ def _create_remediation(self) -> Remediation:
136293
- Use TypeScript strict mode
137294
- Follow ESLint configuration
138295
- Write tests for new features
139-
"""
296+
""",
297+
"""# CLAUDE.md with @ reference (Option 3)
298+
@AGENTS.md
299+
""",
300+
"""# AGENTS.md (shared by multiple tools)
301+
302+
## Project Overview
303+
This project implements a REST API for user management.
304+
305+
## Architecture
306+
- Layered architecture: controllers, services, repositories
307+
- PostgreSQL database with SQLAlchemy ORM
308+
- FastAPI web framework
309+
310+
## Development Workflow
311+
```bash
312+
# Setup
313+
python -m venv .venv
314+
source .venv/bin/activate
315+
pip install -e .
316+
317+
# Run tests
318+
pytest
319+
320+
# Start server
321+
uvicorn app.main:app --reload
322+
```
323+
324+
## Code Conventions
325+
- Use type hints for all functions
326+
- Follow PEP 8 style guide
327+
- Write docstrings for public APIs
328+
- Maintain >80% test coverage
329+
""",
140330
],
141331
citations=[
142332
Citation(
143333
source="Anthropic",
144334
title="Claude Code Documentation",
145335
url="https://docs.anthropic.com/claude-code",
146336
relevance="Official guidance on CLAUDE.md configuration",
147-
)
337+
),
338+
Citation(
339+
source="agents.md",
340+
title="AGENTS.md Specification",
341+
url="https://agents.md/",
342+
relevance="Emerging standard for cross-tool AI assistant configuration",
343+
),
148344
],
149345
)
150346

0 commit comments

Comments
 (0)