Skip to content

Commit f923efe

Browse files
committed
fix: get_file_context honours the ignore contract (#147)
1 parent d9fd1c7 commit f923efe

2 files changed

Lines changed: 72 additions & 1 deletion

File tree

‎src/diffctx/mcp/server.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,10 @@ def _build() -> str:
313313
"Examples:\n"
314314
'- patterns=["src/**/*.py"] — all Python files\n'
315315
'- patterns=["eval/*.py", "tests/conftest.py"] — specific sets\n'
316-
'- patterns=["*.md"] with dry_run=true — preview what matches' + _UNTRUSTED_NOTICE
316+
'- patterns=["*.md"] with dry_run=true — preview what matches\n\n'
317+
"Honours .gitignore and .diffctx/ignore: excluded files are never returned "
318+
"and are not reported as truncated, so a glob cannot read what the repo "
319+
"withholds from the other tools." + _UNTRUSTED_NOTICE
317320
)
318321

319322

@@ -325,8 +328,21 @@ def _is_contained(child: Path, root: Path) -> bool:
325328

326329

327330
def _collect_matched_files(validated_path: Path, patterns: list[str], max_files: int) -> tuple[list[Path], int]:
331+
"""Files matching `patterns`, minus anything the repo declared off-limits.
332+
333+
The ignore specs are applied here for the same reason `get_tree_map` applies
334+
them: `.diffctx/ignore` is a security contract, and this tool accepts
335+
`**/*`. Without the specs the two MCP tools disagreed about the same repo —
336+
a key or an env file that diff mode and the tree map both withhold was
337+
readable through an explicit glob, which makes the contract advisory rather
338+
than a contract.
339+
"""
328340
import glob as globmod
329341

342+
from diffctx.ignore import get_ignore_specs
343+
344+
spec = get_ignore_specs(validated_path, None, False, None)
345+
330346
matched: list[Path] = []
331347
seen: set[Path] = set()
332348
total_matched = 0
@@ -336,6 +352,15 @@ def _collect_matched_files(validated_path: Path, patterns: list[str], max_files:
336352
p = Path(match)
337353
if not p.is_file() or not _is_contained(p, validated_path):
338354
continue
355+
try:
356+
rel = p.resolve().relative_to(validated_path.resolve()).as_posix()
357+
except ValueError:
358+
continue
359+
if spec.match_file(rel):
360+
# Excluded silently and NOT counted: `total_matched` drives the
361+
# truncation notice, and reporting "3 more files" for files the
362+
# repo refuses to expose would leak their existence.
363+
continue
339364
resolved = p.resolve()
340365
if resolved in seen:
341366
continue

‎tests/test_mcp.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,3 +770,49 @@ async def test_ordinary_range_is_unaffected(self, server, mcp_repo):
770770
{"repo_path": str(mcp_repo.path), "diff_range": "HEAD~1..HEAD"},
771771
)
772772
assert "calc.py" in _get_text(result)
773+
774+
775+
class TestFileContextHonoursTheIgnoreContract:
776+
"""`get_file_context` accepts `**/*`, so it is the widest read surface the
777+
MCP server exposes. It used to glob without the ignore specs that
778+
`get_tree_map` and diff mode both apply, which made `.diffctx/ignore` — a
779+
security contract per QA.md — advisory for anyone who asked directly."""
780+
781+
@staticmethod
782+
def _repo(tmp_path):
783+
root = tmp_path / "ignored_repo"
784+
(root / ".diffctx").mkdir(parents=True)
785+
(root / ".diffctx" / "ignore").write_text("secrets/\n*.key\n")
786+
(root / "secrets").mkdir()
787+
(root / "secrets" / "prod.env").write_text("API_TOKEN=must-not-surface\n")
788+
(root / "deploy.key").write_text("PRIVATE-KEY-must-not-surface\n")
789+
(root / "app.py").write_text("def run():\n return 1\n")
790+
return root
791+
792+
@pytest.mark.asyncio
793+
async def test_a_declared_exclusion_is_not_readable_through_a_glob(self, server, tmp_path):
794+
root = self._repo(tmp_path)
795+
result = await server.call_tool(
796+
"get_file_context",
797+
{"repo_path": str(root), "patterns": ["**/*"]},
798+
)
799+
text = _get_text(result)
800+
801+
assert "must-not-surface" not in text, "an ignored file's contents were returned"
802+
assert "prod.env" not in text
803+
assert "deploy.key" not in text
804+
assert "app.py" in text, "the fix must not stop ordinary files being read"
805+
806+
@pytest.mark.asyncio
807+
async def test_an_excluded_file_is_not_counted_in_the_truncation_notice(self, server, tmp_path):
808+
"""Reporting "N more files" for withheld files would leak that they
809+
exist, which is most of what the exclusion is protecting."""
810+
root = self._repo(tmp_path)
811+
result = await server.call_tool(
812+
"get_file_context",
813+
{"repo_path": str(root), "patterns": ["**/*"], "dry_run": True},
814+
)
815+
text = _get_text(result)
816+
817+
assert "secrets" not in text
818+
assert "deploy.key" not in text

0 commit comments

Comments
 (0)