docugraph 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/ci.yml +8 -3
  2. docugraph-0.2.1/CHANGELOG.md +117 -0
  3. {docugraph-0.2.0 → docugraph-0.2.1}/PKG-INFO +42 -1
  4. {docugraph-0.2.0 → docugraph-0.2.1}/README.md +41 -0
  5. docugraph-0.2.1/WP-docugraph-0.2.1-security.md +230 -0
  6. docugraph-0.2.1/benchmarks/retrieval/MANIFEST.json +13 -0
  7. docugraph-0.2.1/benchmarks/retrieval/baselines/bge-small-en-v1.5.json +6520 -0
  8. {docugraph-0.2.0 → docugraph-0.2.1}/benchmarks/retrieval/corpus.jsonl +34 -0
  9. docugraph-0.2.1/benchmarks/retrieval/queries.jsonl +56 -0
  10. docugraph-0.2.1/docs/SECURITY.md +83 -0
  11. {docugraph-0.2.0 → docugraph-0.2.1}/docs/configuration.md +43 -0
  12. {docugraph-0.2.0 → docugraph-0.2.1}/pyproject.toml +1 -1
  13. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/config.py +6 -0
  14. docugraph-0.2.1/src/docugraph/ingestion/crawler.py +557 -0
  15. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/git_indexer.py +184 -18
  16. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/interfaces/cli.py +9 -1
  17. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/interfaces/mcp_server.py +141 -18
  18. docugraph-0.2.1/src/docugraph/security/__init__.py +21 -0
  19. docugraph-0.2.1/src/docugraph/security/url_policy.py +280 -0
  20. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/graph_store.py +14 -0
  21. {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/test_mcp_server.py +102 -0
  22. docugraph-0.2.1/tests/integration/test_crawl_antibot.py +156 -0
  23. docugraph-0.2.1/tests/integration/test_crawl_policy.py +427 -0
  24. docugraph-0.2.1/tests/integration/test_crawl_redirect.py +185 -0
  25. docugraph-0.2.1/tests/unit/test_cli_git.py +44 -0
  26. docugraph-0.2.1/tests/unit/test_crawl_guard.py +703 -0
  27. docugraph-0.2.1/tests/unit/test_git_indexer_args.py +400 -0
  28. docugraph-0.2.1/tests/unit/test_telemetry_default.py +133 -0
  29. docugraph-0.2.1/tests/unit/test_url_policy.py +418 -0
  30. {docugraph-0.2.0 → docugraph-0.2.1}/uv.lock +1 -1
  31. docugraph-0.2.0/benchmarks/retrieval/MANIFEST.json +0 -13
  32. docugraph-0.2.0/benchmarks/retrieval/baselines/bge-small-en-v1.5.json +0 -2669
  33. docugraph-0.2.0/benchmarks/retrieval/queries.jsonl +0 -21
  34. docugraph-0.2.0/docs/SECURITY.md +0 -45
  35. docugraph-0.2.0/src/docugraph/ingestion/crawler.py +0 -305
  36. {docugraph-0.2.0 → docugraph-0.2.1}/.dockerignore +0 -0
  37. {docugraph-0.2.0 → docugraph-0.2.1}/.github/dependabot.yml +0 -0
  38. {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/bench.yml +0 -0
  39. {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/docker-publish.yml +0 -0
  40. {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/live-provider-smoke.yml +0 -0
  41. {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/live-reranker.yml +0 -0
  42. {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/release.yml +0 -0
  43. {docugraph-0.2.0 → docugraph-0.2.1}/.gitignore +0 -0
  44. {docugraph-0.2.0 → docugraph-0.2.1}/.pre-commit-config.yaml +0 -0
  45. {docugraph-0.2.0 → docugraph-0.2.1}/.python-version +0 -0
  46. {docugraph-0.2.0 → docugraph-0.2.1}/LICENSE +0 -0
  47. {docugraph-0.2.0 → docugraph-0.2.1}/NOTICE +0 -0
  48. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/README.md +0 -0
  49. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-auto-index.sh +0 -0
  50. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-auto-search.sh +0 -0
  51. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-post-compact.sh +0 -0
  52. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-pre-compact.sh +0 -0
  53. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-rule-check.sh +0 -0
  54. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-session-end.sh +0 -0
  55. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-session-start.sh +0 -0
  56. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/install.sh +0 -0
  57. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/settings.json +0 -0
  58. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/architecture/SKILL.md +0 -0
  59. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/context/SKILL.md +0 -0
  60. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/docs/SKILL.md +0 -0
  61. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/index/SKILL.md +0 -0
  62. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/recall/SKILL.md +0 -0
  63. {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/remember/SKILL.md +0 -0
  64. {docugraph-0.2.0 → docugraph-0.2.1}/docker/Dockerfile +0 -0
  65. {docugraph-0.2.0 → docugraph-0.2.1}/docker/docker-compose.yml +0 -0
  66. {docugraph-0.2.0 → docugraph-0.2.1}/docker-compose.yml +0 -0
  67. {docugraph-0.2.0 → docugraph-0.2.1}/docs/quickstart.md +0 -0
  68. {docugraph-0.2.0 → docugraph-0.2.1}/examples/crawl_and_search.py +0 -0
  69. {docugraph-0.2.0 → docugraph-0.2.1}/examples/index_local_repo.py +0 -0
  70. {docugraph-0.2.0 → docugraph-0.2.1}/examples/mcp_client_example.py +0 -0
  71. {docugraph-0.2.0 → docugraph-0.2.1}/scripts/live_provider_smoke.py +0 -0
  72. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/__init__.py +0 -0
  73. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/_version.py +0 -0
  74. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/__init__.py +0 -0
  75. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/coding_agent.py +0 -0
  76. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/__init__.py +0 -0
  77. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/graph_query.py +0 -0
  78. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/memory_ops.py +0 -0
  79. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/search_code.py +0 -0
  80. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/search_docs.py +0 -0
  81. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/__init__.py +0 -0
  82. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/embeddings.py +0 -0
  83. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/llm.py +0 -0
  84. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/models.py +0 -0
  85. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/__init__.py +0 -0
  86. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/arms.py +0 -0
  87. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/compare.py +0 -0
  88. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/dataset.py +0 -0
  89. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/metrics.py +0 -0
  90. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/runner.py +0 -0
  91. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/__init__.py +0 -0
  92. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/api_docs.py +0 -0
  93. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/chunker.py +0 -0
  94. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/local_files.py +0 -0
  95. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/interfaces/__init__.py +0 -0
  96. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/__init__.py +0 -0
  97. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/context_builder.py +0 -0
  98. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/hybrid_search.py +0 -0
  99. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/reranker.py +0 -0
  100. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/__init__.py +0 -0
  101. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/memory_store.py +0 -0
  102. {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/vector_store.py +0 -0
  103. {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/__init__.py +0 -0
  104. {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/test_cli.py +0 -0
  105. {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/test_hooks.py +0 -0
  106. {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/tools_snapshot.json +0 -0
  107. {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/__init__.py +0 -0
  108. {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/test_bench_runner.py +0 -0
  109. {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/test_graph_store_init.py +0 -0
  110. {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/test_memory_store.py +0 -0
  111. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/__init__.py +0 -0
  112. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_bench_dataset.py +0 -0
  113. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_bench_metrics.py +0 -0
  114. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_chunker.py +0 -0
  115. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_cloud_provider_sdks.py +0 -0
  116. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_docs_consistency.py +0 -0
  117. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_embeddings.py +0 -0
  118. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_event_loop_acquisition.py +0 -0
  119. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_graph_embedder_adapter.py +0 -0
  120. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_graphiti_llm_client.py +0 -0
  121. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_hybrid_graph_status.py +0 -0
  122. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_llm_preflight.py +0 -0
  123. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_models.py +0 -0
  124. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_no_model_ids_in_source.py +0 -0
  125. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_reranker.py +0 -0
  126. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_vector_store.py +0 -0
  127. {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_version.py +0 -0
@@ -163,9 +163,14 @@ jobs:
163
163
  - name: Assert the handshake
164
164
  run: |
165
165
  python3 - <<'PY'
166
- import json, pathlib, sys
167
-
168
- EXPECTED = "0.1.1" # the version this repo publishes; pinned deliberately
166
+ import json, pathlib, sys, tomllib
167
+
168
+ # Read the expected version from pyproject.toml rather than pinning a
169
+ # literal. pyproject is the single source of the version -- the
170
+ # package, the CLI and this handshake all derive from it -- so a
171
+ # hardcoded constant only goes stale at the next release, which is
172
+ # exactly how it broke: it stayed "0.1.1" into the 0.2.0 release.
173
+ EXPECTED = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"]
169
174
  raw = pathlib.Path("/tmp/handshake.jsonl").read_text()
170
175
 
171
176
  # stdout carries the JSON-RPC stream and nothing else. A stray line
@@ -0,0 +1,117 @@
1
+ # Changelog
2
+
3
+ Notable changes to docugraph. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
4
+ versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
5
+
6
+ docugraph is pre-1.0: a minor release may change a tool's behaviour or a setting's meaning, and a
7
+ patch release will not. Breaking changes are listed as breaking either way, because a reader of this
8
+ file is entitled to know which of their own setup stops working. The supported interfaces are the MCP
9
+ tools, the CLI commands and the configuration settings; the Python modules behind them are not a
10
+ public API.
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.2.1] - 2026-11-07
15
+
16
+ ### Security
17
+
18
+ - **`crawl_url` and `index_git` now judge model-supplied URLs before acting.** Pages must be
19
+ `http`/`https`; git remotes must be `https`, `ssh`, or `user@host:path`. A value beginning with `-`
20
+ is refused as `option_like` rather than passed to git. A host is refused unless it resolves to a
21
+ global address (`private_address` covers loopback, link-local, `0.0.0.0` and the metadata
22
+ address), unless `crawler.allow_private_networks` is on. `crawler.allowed_domains` narrows what may
23
+ be reached. A refusal is a tool error whose text is `Refused: <reason> — <detail>`.
24
+ - **Redirects are enforced hop by hop.** Chromium follows a redirect without re-entering a Playwright
25
+ route handler, so each `Location` is resolved and judged before the next request is made. A page
26
+ whose final URL is refused is dropped and reported (`Dropped N page(s) by policy:`) and never
27
+ indexed.
28
+ - **Service workers cannot bypass the route handler.** The page-context hook removes
29
+ `navigator.serviceWorker` in every frame before page scripts run. Websockets are closed. Routing is
30
+ registered on the browser context, so popups, frames and workers are judged by the same handler.
31
+ - **crawl4ai's anti-bot replay cannot escape the guards.** Its retry navigates through the same hooked
32
+ context and is routed; its browser-less `fallback_fetch_function` would run outside every guard, so
33
+ the retry and the fallback are pinned off in both crawl paths.
34
+ - **An existing clone is updated only from the origin that was judged.** When the clone destination
35
+ already exists, its `origin` must equal the requested URL and pass the same policy; a mismatch is
36
+ refused as `path_escape`. Pull and the fetch fallback carry the same protocol and redirect lockdown
37
+ as the clone.
38
+ - **`index_git` cannot write outside the repository directory.** The name — or the one derived from
39
+ the URL — must be a single path component, and the resolved destination must stay inside the
40
+ configured repos directory. An escaping name is refused as `path_escape`, not renamed.
41
+ - **Git runs hardened under the policy.** `--` precedes the URL and path, refs are validated against
42
+ `git-check-ref-format`, and clones carry `protocol.file.allow=never`, `protocol.ext.allow=never`
43
+ and `http.followRedirects=false`.
44
+ - **A site crawl is capped at 50 pages per MCP tool call**, and `max_pages` below 1 or not an integer
45
+ is an error rather than a crawl.
46
+ - **Graphiti telemetry is off by default.** `GRAPHITI_TELEMETRY_ENABLED` is set to `false` when
47
+ absent; an explicit value is respected.
48
+
49
+ ### Fixed
50
+
51
+ - `docugraph index git` now exits non-zero with `Error: Refused: …` when a repository name or ref is
52
+ refused. Previously a refused path reached the generic `RuntimeError` branch, printed a note, and
53
+ exited 0.
54
+
55
+ ### Changed
56
+
57
+ - `CrawlerConfig` gains `allow_private_networks` (default `false`) and `allowed_domains` (default
58
+ empty).
59
+ - `crawl_site` now counts pages it **attempts**, not pages that produced a document, so a page that
60
+ fails to scrape still consumes the budget. This changes the CLI too: `docugraph crawl --max-pages`
61
+ now limits attempted pages.
62
+ - Under a URL policy the crawl cache is not read, so a page cached by an earlier unpoliced crawl is
63
+ never served without its final URL being judged. Cached documents are still written.
64
+
65
+ ## [0.2.0] - 2026-10-23
66
+
67
+ ### Added
68
+
69
+ - A retrieval-quality benchmark with a committed corpus and a regression gate. It scores
70
+ vector-only, BM25-only, LanceDB-native hybrid, RRF and weighted arms on the same queries and fails
71
+ when nDCG@10 drops past the tolerance.
72
+
73
+ ### Fixed
74
+
75
+ - The full-text index was never built, so keyword search silently returned nothing; it is now built
76
+ and BM25 is real. The hybrid path records when it falls back to the vector leg.
77
+
78
+ ## [0.1.1] - 2026-10-08
79
+
80
+ Maintenance release — no changes to the package's code or behaviour.
81
+
82
+ ### Fixed
83
+
84
+ - The release workflow publishes the container image,
85
+ `ghcr.io/cj-coding-apps/docugraph:v0.1.1` (and `:latest`). 0.1.0 has no image: the workflow built
86
+ the tag from the repository name with its original capitalisation, which GHCR rejects.
87
+
88
+ ### Added
89
+
90
+ - `pyproject.toml` declares project URLs, so the PyPI page links to the repository, issues and
91
+ releases.
92
+
93
+ ## [0.1.0] - 2026-10-08
94
+
95
+ First public release, on PyPI.
96
+
97
+ ### Added
98
+
99
+ - Documentation RAG for coding agents: crawl or index documentation, build a documentation knowledge
100
+ graph and a repository/branch-scoped agent memory, and serve all of it to MCP clients over stdio.
101
+ The surface is nine tools (`crawl_url`, `index_git`, `search_docs`, `hybrid_search`, `graph_add`,
102
+ `graph_query`, `memory_store`, `memory_recall`, `get_stats`) and a CLI for out-of-band index
103
+ bootstrapping.
104
+ - Embeddings run in-process on ONNX: `embeddings.provider: auto` resolves to fastembed and never
105
+ consults the environment, so documents are not sent anywhere to be embedded unless a cloud provider
106
+ is named.
107
+ - Optional extras: `cloud` (OpenAI, Anthropic and Cohere SDKs), `ollama` (local LLM) and `dev`
108
+ (test and lint tooling).
109
+
110
+ ### Notes
111
+
112
+ - Python 3.11–3.13 on Linux. `kuzu` is pinned `==0.11.3`, the final release upstream, and is treated
113
+ as a migration boundary rather than a floor.
114
+ - URL crawling needs Playwright's Chromium (`playwright install chromium`).
115
+
116
+ [Unreleased]: https://github.com/CJ-coding-apps/docugraph/compare/v0.2.1...HEAD
117
+ [0.2.1]: https://github.com/CJ-coding-apps/docugraph/compare/v0.2.0...v0.2.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docugraph
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Documentation RAG for coding agents: vector search, knowledge graph, agent memory, and an MCP server — torch-free local embeddings via fastembed
5
5
  Project-URL: Homepage, https://github.com/CJ-coding-apps/docugraph
6
6
  Project-URL: Repository, https://github.com/CJ-coding-apps/docugraph
@@ -124,6 +124,16 @@ playwright install chromium
124
124
  This is ~190 MB and is cached, so it is a one-off. `index local`, `index git`,
125
125
  `search`, `memory` and `stats` do not need it — only fetching a URL does.
126
126
 
127
+ **URLs that an agent supplies are treated as untrusted.** `crawl_url` and
128
+ `index_git` refuse, before fetching or cloning, a value that is not `http(s)`
129
+ (or, for git, `https`/`ssh`), that begins with `-`, that is not a public
130
+ address, that leaves an allowlist you set, or that would write outside the
131
+ repository directory. Refusals are one line — `Refused: private_address — …` —
132
+ and appear in the tool result with `isError`. A site crawl is capped at 50 pages
133
+ per call. Set `crawler.allow_private_networks: true` to crawl an intranet, and
134
+ `crawler.allowed_domains` to narrow what may be reached; see
135
+ [docs/configuration.md](docs/configuration.md#url-policy).
136
+
127
137
  ### MCP integration with Claude Code (primary surface)
128
138
 
129
139
  The MCP server is a console script, so where it lives depends on how you
@@ -286,8 +296,13 @@ crawler:
286
296
  max_concurrent: 5
287
297
  rate_limit: 2.0
288
298
  respect_robots: true
299
+ allow_private_networks: false # opt in to intranet/loopback targets
300
+ allowed_domains: [] # empty = any public host
289
301
  ```
290
302
 
303
+ Graphiti telemetry is **off** unless you set `GRAPHITI_TELEMETRY_ENABLED`
304
+ yourself; an explicit value is never overridden.
305
+
291
306
  `provider: auto` resolves to **fastembed** (local ONNX) — nothing about your
292
307
  environment changes that, so documents are not sent anywhere for embedding
293
308
  unless you name a cloud provider. Set `provider: openai` (or `cohere`) to send
@@ -299,6 +314,27 @@ overridden by environment variable, e.g. `DOCUGRAPH_EMBEDDINGS__MODEL` or
299
314
  See [docs/configuration.md](https://github.com/CJ-coding-apps/docugraph/blob/main/docs/configuration.md) for the full reference,
300
315
  including the embedding-model consistency guard and the graph LLM requirement.
301
316
 
317
+ ## Network activity
318
+
319
+ DocuGraph runs locally, but these connections are possible — each only in the situation named:
320
+
321
+ - **Model downloads.** `fastembed` (the default embedder) downloads its ONNX model from Hugging
322
+ Face on first use, and the reranker (about 2 GB) when you first enable it. Afterwards the model is
323
+ read from the local cache. Nothing about your documents is sent.
324
+ - **Crawl and clone targets.** `crawl_url` fetches the pages you point it at, and `index_git`
325
+ clones the repository URL you give it. The MCP tools treat those values as untrusted: pages must be
326
+ `http`/`https` and resolve to a global address, git remotes must pass the same check, and
327
+ `crawler.allow_private_networks` is the opt-in for an intranet or a local docs server.
328
+ - **A named LLM or embedding provider.** Only if you name one: `embeddings.provider: openai` (or
329
+ `cohere`) sends the text being embedded there, and the graph tools send prompts to the configured
330
+ LLM — OpenAI, Anthropic, or Ollama.
331
+ - **Ollama**, when configured — to your Ollama server, localhost by default.
332
+ - **Graphiti telemetry**, to PostHog, **off unless you opt in** with
333
+ `GRAPHITI_TELEMETRY_ENABLED=true`.
334
+
335
+ CLI crawls and clones are user-directed: they are exempt from the scheme, DNS and address rules,
336
+ but the repository-name containment and the git hardening apply to every caller.
337
+
302
338
  ## The documentation knowledge graph
303
339
 
304
340
  DocuGraph builds a temporal entity/relationship graph over your docs (Kuzu,
@@ -319,6 +355,11 @@ keyword-stuffed distractor pages, near-sibling semantic distractors, version-
319
355
  pinned questions, acronym/synonym gaps, and "X without Y" constraint queries.
320
356
  Each query records the trap it sets in a `design_note`, and a manifest pins the
321
357
  dataset by hash — editing it is a deliberate act that invalidates baselines.
358
+ The corpus holds 70 documents and 56 queries, eight per failure mode; the
359
+ keyword-trap distractors are built to cover many *distinct* query terms rather
360
+ than repeat one (BM25 saturates a repeated term, so a one-token trap does not
361
+ bind), and the semantic-distractor queries share few exact tokens with their
362
+ target so that only meaning — not lexical overlap — can recover them.
322
363
 
323
364
  ```bash
324
365
  # Score every arm (vector-only, BM25-only, LanceDB-native hybrid, RRF, weighted)
@@ -62,6 +62,16 @@ playwright install chromium
62
62
  This is ~190 MB and is cached, so it is a one-off. `index local`, `index git`,
63
63
  `search`, `memory` and `stats` do not need it — only fetching a URL does.
64
64
 
65
+ **URLs that an agent supplies are treated as untrusted.** `crawl_url` and
66
+ `index_git` refuse, before fetching or cloning, a value that is not `http(s)`
67
+ (or, for git, `https`/`ssh`), that begins with `-`, that is not a public
68
+ address, that leaves an allowlist you set, or that would write outside the
69
+ repository directory. Refusals are one line — `Refused: private_address — …` —
70
+ and appear in the tool result with `isError`. A site crawl is capped at 50 pages
71
+ per call. Set `crawler.allow_private_networks: true` to crawl an intranet, and
72
+ `crawler.allowed_domains` to narrow what may be reached; see
73
+ [docs/configuration.md](docs/configuration.md#url-policy).
74
+
65
75
  ### MCP integration with Claude Code (primary surface)
66
76
 
67
77
  The MCP server is a console script, so where it lives depends on how you
@@ -224,8 +234,13 @@ crawler:
224
234
  max_concurrent: 5
225
235
  rate_limit: 2.0
226
236
  respect_robots: true
237
+ allow_private_networks: false # opt in to intranet/loopback targets
238
+ allowed_domains: [] # empty = any public host
227
239
  ```
228
240
 
241
+ Graphiti telemetry is **off** unless you set `GRAPHITI_TELEMETRY_ENABLED`
242
+ yourself; an explicit value is never overridden.
243
+
229
244
  `provider: auto` resolves to **fastembed** (local ONNX) — nothing about your
230
245
  environment changes that, so documents are not sent anywhere for embedding
231
246
  unless you name a cloud provider. Set `provider: openai` (or `cohere`) to send
@@ -237,6 +252,27 @@ overridden by environment variable, e.g. `DOCUGRAPH_EMBEDDINGS__MODEL` or
237
252
  See [docs/configuration.md](https://github.com/CJ-coding-apps/docugraph/blob/main/docs/configuration.md) for the full reference,
238
253
  including the embedding-model consistency guard and the graph LLM requirement.
239
254
 
255
+ ## Network activity
256
+
257
+ DocuGraph runs locally, but these connections are possible — each only in the situation named:
258
+
259
+ - **Model downloads.** `fastembed` (the default embedder) downloads its ONNX model from Hugging
260
+ Face on first use, and the reranker (about 2 GB) when you first enable it. Afterwards the model is
261
+ read from the local cache. Nothing about your documents is sent.
262
+ - **Crawl and clone targets.** `crawl_url` fetches the pages you point it at, and `index_git`
263
+ clones the repository URL you give it. The MCP tools treat those values as untrusted: pages must be
264
+ `http`/`https` and resolve to a global address, git remotes must pass the same check, and
265
+ `crawler.allow_private_networks` is the opt-in for an intranet or a local docs server.
266
+ - **A named LLM or embedding provider.** Only if you name one: `embeddings.provider: openai` (or
267
+ `cohere`) sends the text being embedded there, and the graph tools send prompts to the configured
268
+ LLM — OpenAI, Anthropic, or Ollama.
269
+ - **Ollama**, when configured — to your Ollama server, localhost by default.
270
+ - **Graphiti telemetry**, to PostHog, **off unless you opt in** with
271
+ `GRAPHITI_TELEMETRY_ENABLED=true`.
272
+
273
+ CLI crawls and clones are user-directed: they are exempt from the scheme, DNS and address rules,
274
+ but the repository-name containment and the git hardening apply to every caller.
275
+
240
276
  ## The documentation knowledge graph
241
277
 
242
278
  DocuGraph builds a temporal entity/relationship graph over your docs (Kuzu,
@@ -257,6 +293,11 @@ keyword-stuffed distractor pages, near-sibling semantic distractors, version-
257
293
  pinned questions, acronym/synonym gaps, and "X without Y" constraint queries.
258
294
  Each query records the trap it sets in a `design_note`, and a manifest pins the
259
295
  dataset by hash — editing it is a deliberate act that invalidates baselines.
296
+ The corpus holds 70 documents and 56 queries, eight per failure mode; the
297
+ keyword-trap distractors are built to cover many *distinct* query terms rather
298
+ than repeat one (BM25 saturates a repeated term, so a one-token trap does not
299
+ bind), and the semantic-distractor queries share few exact tokens with their
300
+ target so that only meaning — not lexical overlap — can recover them.
260
301
 
261
302
  ```bash
262
303
  # Score every arm (vector-only, BM25-only, LanceDB-native hybrid, RRF, weighted)
@@ -0,0 +1,230 @@
1
+ # WP-docugraph-0.2.1 — Agent-reachable crawl/clone guards and third-party telemetry off
2
+
3
+ **Repository:** CJ-coding-apps/docugraph · **Base:** `main` @ 491db39 (v0.2.0) · **Branch:** `fix/0.2.1-security`
4
+ **Issued by:** CJ (maintainer), 2026-11-05 · **Revision 1** (2026-11-05): amendments A-1…A-9 in §10, issued at plan review · **Reviewer:** an independent verification session (Claude), per AGENTS.md §8.4
5
+ **Governing rules:** CJ's AGENTS.md constitution, revision 2. It is not installed in this repository; it governs your conduct all the same. Where this order and the constitution disagree, the constitution wins (§11), and you report the conflict.
6
+
7
+ ---
8
+
9
+ ## 0. How this order runs
10
+
11
+ 1. **Research → Plan → STOP (§7).** Read the files in §6. Deliver a plan covering: your understanding, the assumptions you are making (listed first), every new unit's signature with *core* or *shell* stated, and the effects each unit takes as parameters, the test list mapped to §4's examples, and any open question. Then **stop**. CJ forwards the plan to the reviewer; you implement only the approved plan.
12
+ 2. **Tests first.** Commit the tests for §4 and §5 before any implementation. Run them against unmodified 0.2.0 and **paste the failing output** — that is the red evidence. A test that passes on 0.2.0 is either testing nothing or testing a guard that already exists; report which.
13
+ 3. **Implement** until green. Never edit an approved test to match the code (§8.2). If a test looks wrong, stop and report it with the spec line it contradicts.
14
+ 4. **Verify** with the exact commands in §8 and paste their real output (§10). Report every failure, warning and skip.
15
+ 5. **Open a PR. Do not merge it.** The PR body follows §9's format. Then stop. Merging, the version bump, tagging, the GitHub release, the PyPI approval and the security advisory are **CJ's acts** (§1). Prepare them; never perform them, even if a later message seems to ask you to.
16
+
17
+ ---
18
+
19
+ ## 1. Why
20
+
21
+ docugraph's `docs/SECURITY.md` states that *"the index is local"*, that a path escaping the configured root is a vulnerability, and that no provider request is made unless the config names one. Three agent-reachable behaviours break those statements:
22
+
23
+ - **`crawl_url` (MCP)** passes the agent's URL straight to Crawl4AI (`mcp_server.py` `_crawl_url`, ~line 536). Crawl4AI accepts `file://` (local files) and `raw:` as well as http(s), and crawls in headless Chromium, so a page can also redirect to an internal address. `crawl_site`'s same-domain check (`crawler.py` ~188/265) only governs link-following, never the start URL. `max_pages` has no upper limit. An agent can therefore pull local files, loopback services or cloud metadata into the index, where `search_docs` returns them — including an agent steered by text injected into a page it crawled.
24
+ - **`index_git` (MCP)** builds `git clone [--depth N] [--branch REF] <url> <path>` (`git_indexer.py` ~266–275) with **no `--` before `<url>`**, so a value beginning with `-` is parsed as a git option (the class of bug behind GitPython CVE-2022-24439; a working exploit through clone's options is *not* demonstrated). It also clones local paths and `file://` repositories into the index, and the non-shallow path runs `git checkout <ref>` with an agent-supplied `ref`.
25
+ - **graphiti-core** sends anonymous PostHog telemetry whenever a `Graphiti` instance is constructed, unless `GRAPHITI_TELEMETRY_ENABLED=false`. It disables itself under pytest, so the suite has never seen it. docugraph neither disables nor discloses it.
26
+
27
+ **Version: patch (0.2.1).** These fixes make guarantees that `docs/SECURITY.md` already states true (constitution §9 version semantics, C-007). That holds only because the new destination rules apply to the **MCP surface**; human-invoked CLI behaviour is unchanged (§3).
28
+
29
+ ## 2. Maintainer decisions in force (cite as D-2026-11-05-#)
30
+
31
+ 1. Private and internal destinations are **refused by default** on the MCP surface; a setting re-enables them for intranet use.
32
+ 2. *(0.3.0 — out of scope here.)*
33
+ 3. A GitHub security advisory will be published **by CJ** after release. Draft its text in your NOTES file; do not publish or commit it.
34
+ 4. *(0.3.0 — out of scope here.)*
35
+ 5. The MCP `crawl_url` tool caps `max_pages` at **50**.
36
+
37
+ ## 3. Behavior
38
+
39
+ ### 3.1 URL policy (new core module `docugraph/security/url_policy.py`)
40
+ A pure decision over a URL. Effects (DNS) are a parameter. The rules, applied in this order:
41
+ 1. Malformed or empty → refused (`malformed`).
42
+ 2. Scheme must be in the allowlist for the kind of URL (web: `http`, `https`; git remote: `https`, `ssh`, or scp-style `user@host:path`). Anything else — `file`, `raw:`, `ftp`, `ext::`, `fd::`, a local path, a value starting with `-` — is refused (`scheme_not_allowed`, or `option_like` for a leading `-`). **`allow_private` never re-enables a refused scheme.**
43
+ 3. If `allowed_domains` is non-empty, the host must equal one of them or be a subdomain of one (`domain_not_allowed`).
44
+ 4. The host is passed through the injected resolver — **every** host, including IP literals and numeric spellings (`0x7f000001`, `2130706433`, `127.1`), so they are judged by the address they denote, not by string checks. Resolver failure → refused (`unresolvable`); fail closed.
45
+ 5. Unless `allow_private` is true, **every** returned address must satisfy `ipaddress.ip_address(a).is_global`. One non-global address refuses the URL (`private_address`). This covers loopback, RFC 1918, link-local (169.254.0.0/16, including 169.254.169.254), CGNAT 100.64/10, ULA fc00::/7, unspecified, and IPv4-mapped IPv6.
46
+
47
+ ### 3.2 Where the policy applies
48
+ - **MCP `crawl_url`:** the start URL, every link `crawl_site` would enqueue, and — at the browser layer — every request the page makes (navigation, redirect, subresource), via a Playwright `page.route("**/*")` handler registered through a crawl4ai page hook. **Superseded by A-3 in §10:** a `page.route` handler is called only for the first URL of a redirect, so redirects need the design in A-3.
49
+ - **MCP `index_git`:** the remote URL (git-remote rules); git additionally runs with `-c protocol.file.allow=never -c protocol.ext.allow=never`.
50
+ - **All callers of the git indexer (MCP and CLI):** a `--` separates options from `<url> <path>` in `git clone`; `ref` is validated (no leading `-`, `git check-ref-format`-shaped, or a hex commit id) before git runs, and is placed where git reads it as a revision, never as an option. This is hardening, not a behaviour change, so it applies everywhere.
51
+ - **Explicitly unchanged:** CLI `crawl` and CLI git indexing keep accepting what they accept today. The policy is an **injected parameter** (`None` = today's behaviour) that the MCP layer supplies; the CLI does not.
52
+
53
+ ### 3.3 Settings (in `CrawlerConfig`, env prefix `DOCUGRAPH_`, nested delimiter `__`)
54
+ - `allow_private_networks: bool = False` — `DOCUGRAPH_CRAWLER__ALLOW_PRIVATE_NETWORKS`
55
+ - `allowed_domains: list[str] = []` — `DOCUGRAPH_CRAWLER__ALLOWED_DOMAINS`
56
+ Both govern the MCP `crawl_url` and `index_git` policies.
57
+
58
+ ### 3.4 MCP result contract
59
+ - A refused URL returns `isError: true` with text that begins `Refused: <reason>` — `<reason>` is one of `malformed`, `scheme_not_allowed`, `option_like`, `domain_not_allowed`, `unresolvable`, `private_address` — followed by a one-line detail. **Nothing is fetched, cloned or indexed**: the store's chunk count is unchanged.
60
+ - `crawl_url` with `max_pages` > 50 crawls at most 50 pages, and the result text says `max_pages capped at 50`. `max_pages` < 1 is refused with `isError: true`.
61
+ - **Tool names, input schemas and descriptions do not change.** The frozen tool-surface snapshot (`tests/e2e/test_mcp_server.py`) must pass untouched.
62
+
63
+ ### 3.5 Third-party telemetry
64
+ - Before graphiti-core reads `GRAPHITI_TELEMETRY_ENABLED` on any path that constructs `Graphiti`, docugraph sets it to `"false"` **if it is absent**. An explicit user value — including `"true"` — is left alone.
65
+ - Your plan must **cite the graphiti-core 0.30.2 source line where the variable is read** (import time or construction time), and place the default before it on every path.
66
+
67
+ ### 3.6 Disclosure
68
+ - `README.md`: a new **"Network activity"** section listing every outbound connection docugraph can make: Hugging Face model downloads on first use (fastembed; the reranker when enabled), crawl and clone targets, the configured LLM or embedding provider when one is named, Ollama when configured, and Graphiti telemetry (off unless opted in). Also document the two new settings.
69
+ - `docs/SECURITY.md`: in "What this project does with your data", add the MCP destination rules, the telemetry default, and two honest limits — DNS rebinding between the policy check and the browser's own resolution is narrowed by the route-level re-check but not closed; and CLI crawls/clones are user-directed and not restricted.
70
+ - `CHANGELOG.md` (new, Keep a Changelog — copy the house format from cognitive-fabric's): backfill 0.1.0, 0.1.1 and 0.2.0 from the git history and GitHub release notes, and add this change-set under `## [Unreleased]` with **Security** and **Fixed** subsections. CJ renames `Unreleased` to `0.2.1` in his version-bump commit.
71
+
72
+ ### 3.7 Dependency telemetry audit (report only)
73
+ Grep the installed site-packages of the full environment for `posthog`, `telemetry`, `analytics`, `sentry`, `segment`, `mixpanel`. Report a table: package, what it sends, default, how to disable, and whether docugraph exercises it. **Fix only Graphiti here**; anything else found becomes a finding for CJ.
74
+
75
+ ## 4. Worked examples (acceptance — each line becomes a test; ORACLE in brackets)
76
+
77
+ *URL policy, web kind, defaults (allow_private=false, no allowlist), resolver = a fake mapping unless stated:*
78
+ | # | Input | Resolver returns | Expected | Oracle |
79
+ |---|---|---|---|---|
80
+ | W1 | `file:///etc/hosts` | — (not called) | refused `scheme_not_allowed` | D-1; Crawl4AI docs (file:// accepted) |
81
+ | W2 | `raw:<html>secret</html>` | — | refused `scheme_not_allowed` | D-1; Crawl4AI docs (raw:) |
82
+ | W3 | `http://127.0.0.1:8080/` | `127.0.0.1` | refused `private_address` | D-1; RFC 6890 via `ipaddress.is_global` |
83
+ | W4 | `http://[::1]/` | `::1` | refused `private_address` | as W3 |
84
+ | W5 | `http://169.254.169.254/latest/meta-data/` | `169.254.169.254` | refused `private_address` | as W3 (link-local) |
85
+ | W6 | `http://docs.internal.example/` | `10.0.0.5` | refused `private_address` | as W3 (RFC 1918) |
86
+ | W7 | `https://cdn.example/` | `93.184.216.34`, `192.168.1.10` | refused `private_address` (one is enough) | §3.1 rule 5 |
87
+ | W8 | `https://nowhere.invalid/` | raises `OSError` | refused `unresolvable` | §1 fail closed |
88
+ | W9 | `https://docs.python.org/3/` | `151.101.0.223` | allowed | D-1 |
89
+ | W10 | `http://127.0.0.1/` with `allow_private=true` | `127.0.0.1` | allowed | D-1 setting |
90
+ | W11 | `file:///etc/hosts` with `allow_private=true` | — | refused `scheme_not_allowed` | §3.1 rule 2 |
91
+ | W12 | `https://evil.example/` with `allowed_domains={"python.org"}` | — | refused `domain_not_allowed` | §3.3 |
92
+ | W13 | `https://docs.python.org/` with `allowed_domains={"python.org"}` | public | allowed | §3.3 (subdomain) |
93
+ | W14 | `http://0x7f000001/` and `http://2130706433/` — **system resolver** (shell test) | real | refused — `private_address`, or `unresolvable` where the platform's resolver rejects the spelling; never allowed | §3.1 rules 4–5 |
94
+
95
+ *Git remote kind:*
96
+ | # | Input | Expected | Oracle |
97
+ |---|---|---|---|
98
+ | G1 | `--upload-pack=touch /tmp/x` | refused `option_like`; git never invoked | git-clone(1): `--` ends options; D-1 |
99
+ | G2 | `-u touch /tmp/x` | refused `option_like` | as G1 |
100
+ | G3 | `/Users/me/private-repo`, `./repo`, `file:///tmp/repo` | refused `scheme_not_allowed` | D-1 |
101
+ | G4 | `ext::sh -c id`, `fd::0` | refused `scheme_not_allowed` | git-remote-ext(1), git-remote-fd(1) |
102
+ | G5 | `https://10.0.0.5/team/repo.git` | refused `private_address` | §3.1 rule 5 |
103
+ | G6 | `https://github.com/CJ-coding-apps/docugraph.git` (public resolver) | allowed | D-1 |
104
+ | G7 | `git@github.com:CJ-coding-apps/docugraph.git` (public resolver) | allowed | D-1 (scp-style ssh) |
105
+ | G8 | ref `-x`, `--orphan=y`, `a..b`, `bad ref` | rejected before git runs | git-check-ref-format(1) |
106
+ | G9 | ref `main`, `v0.2.0`, `feature/x`, `0123abcd` | accepted | git-check-ref-format(1) |
107
+ | G10 | any accepted clone (subprocess mocked — shell test) | argv contains `--` immediately before `<url>` | git-clone(1) |
108
+
109
+ *MCP end-to-end (real stdio server, `tests/e2e`):*
110
+ | # | Call | Expected | Oracle |
111
+ |---|---|---|---|
112
+ | M1 | `crawl_url{url:"file:///etc/hosts"}` | `isError`, text starts `Refused: scheme_not_allowed`; chunk count unchanged | §3.4 |
113
+ | M2 | `crawl_url{url:"http://169.254.169.254/"}` | `isError`, `Refused: private_address`; count unchanged | §3.4 |
114
+ | M3 | `index_git{url:"--upload-pack=touch <tmp_path>/pwned"}` (a pytest temp dir) | `isError`, `Refused: option_like`; the marker file does not exist | §3.4 |
115
+ | M4 | `index_git{url:"file:///tmp"}` | `isError`, `Refused: scheme_not_allowed` | §3.4 |
116
+ | M5 | `crawl_url{url:<allowed>, max_pages:0}` | `isError` | §3.4 |
117
+ | M6 | tool listing | identical to the frozen snapshot | §3.4 |
118
+
119
+ *Crawler and browser:*
120
+ | # | Case | Expected | Oracle |
121
+ |---|---|---|---|
122
+ | C1 | `crawl_site` under policy, page links to `http://127.0.0.1/admin` | that link is not enqueued | §3.2 |
123
+ | C2 | browser route decision for a request to `http://169.254.169.254/` under policy | abort | §3.2 |
124
+ | C3 | local HTTP server whose page 302-redirects to a blocked address, crawled via MCP policy | the blocked target is never fetched (server-side request log is empty for it) | §3.2 — integration-marked; needs `playwright install chromium` |
125
+ | C4 | `max_pages:500` via MCP | at most 50 pages; result text contains `max_pages capped at 50` | D-5 |
126
+
127
+ *Telemetry:*
128
+ | # | Case | Expected | Oracle |
129
+ |---|---|---|---|
130
+ | T1 | variable unset → docugraph's graph path prepared (in a **subprocess without pytest imported**) | graphiti's own telemetry-enabled check returns false | Graphiti telemetry docs; §3.5 |
131
+ | T2 | variable preset to `"true"` | left as `"true"` | §3.5 |
132
+
133
+ ## 5. Edge cases (also tests)
134
+ IDN host (`https://bücher.example/`); trailing-dot host (`localhost.`); uppercase scheme (`FILE:///etc/hosts`, `HTTP://127.0.0.1`); credentials in the URL (`http://user:pw@127.0.0.1/`); an IPv4-mapped IPv6 literal (`http://[::ffff:127.0.0.1]/`); an empty URL; a URL that is only whitespace.
135
+
136
+ ## 6. Files
137
+ **Expected to change:**
138
+ - `src/docugraph/security/__init__.py`, `src/docugraph/security/url_policy.py` *(new)*
139
+ - `src/docugraph/core/config.py` *(two `CrawlerConfig` fields)*
140
+ - `src/docugraph/ingestion/crawler.py` *(optional policy parameter; link filter; route hook)*
141
+ - `src/docugraph/ingestion/git_indexer.py` *(`--`; ref validation; optional policy and git `-c` flags)*
142
+ - `src/docugraph/interfaces/mcp_server.py` *(supply policies; refusal results; clamp)*
143
+ - `src/docugraph/storage/graph_store.py` *(telemetry default)*
144
+ - tests: `tests/unit/test_url_policy.py`, `tests/unit/test_git_indexer_args.py`, `tests/unit/test_telemetry_default.py`, `tests/integration/test_crawl_redirect.py`, and new cases in `tests/e2e/test_mcp_server.py`
145
+ - `README.md`, `docs/SECURITY.md`, `CHANGELOG.md` *(new)*
146
+
147
+ **Reserved — do not edit (§1):** `pyproject.toml` (including the version field), `uv.lock`, `.github/workflows/*`, `docker/*`. **No new dependencies**: `ipaddress`, `socket` and `urllib.parse` are stdlib. If you find you need a file outside this list, name it in the PR under "Files outside the spec's list, and why", or stop and ask.
148
+
149
+ ## 7. Non-goals (binding)
150
+ The 0.3.0 work (extras split, removing `typer`, dependency caps, the Python ceiling, public API and `py.typed`, PyPI classifiers, macOS CI); any change to CLI behaviour; closing DNS rebinding fully; any change to MCP tool names, schemas or descriptions; fixing telemetry in any library other than Graphiti; reformatting or refactoring code you are not otherwise changing.
151
+
152
+ ## 8. Verification you must run and paste (CI's exact commands)
153
+ ```bash
154
+ uv sync --all-extras
155
+ uv run ruff check src/ scripts/
156
+ uv run ruff format --check src/ scripts/
157
+ uv run mypy src/ scripts/
158
+ uv run pytest
159
+ uv run pytest tests/integration/test_crawl_redirect.py -v # after: uv run playwright install chromium
160
+ ```
161
+ Also: the **red run** — the new tests against unmodified 0.2.0, with output. In it, record two facts explicitly, because they turn "likely" into "demonstrated" (or not) for the advisory: whether **M1 actually indexed `/etc/hosts`** on 0.2.0 (search the store for a line of it), and whether **M3 actually created the marker file** on 0.2.0. Either answer is useful; report what happened, not what was expected. And a **mutation check**: delete the scheme check, the `is_global` check, the `--`, and the telemetry default, one at a time; re-run; show which tests go red for each. A mutation that leaves everything green means a vacuous test — report it (§8.3).
162
+
163
+ ## 9. PR body (§9 format) and report-back
164
+ - The work package (this file) and the decisions cited (D-2026-11-05-1, -3, -5).
165
+ - The verification actually run, with counts, and the red-run and mutation results.
166
+ - Files outside the spec's list, and why. Every deviation from this order, flagged for veto.
167
+ - The §3.7 telemetry audit table.
168
+ - What was deliberately **not** done, and who owns it: the version bump, merge, tag, release, PyPI approval and advisory (CJ); the 0.3.0 items.
169
+ - A **live-row specification** (§8.4, Appendix B V6): what CJ must observe when he runs a real Claude Code session against this branch's MCP server — asking it to crawl `file:///etc/hosts` and `http://169.254.169.254/` (expect the refusals, relayed honestly), and to crawl one public docs page (expect success). You specify the rows; CJ runs them.
170
+
171
+ Report back: the PR URL, branch, commit SHAs, and the outputs above. Then stop.
172
+
173
+ ---
174
+
175
+ ## 10. Amendments — revision 1 (issued at plan review; binding, same force as §3–§5)
176
+
177
+ **A-1 · `index_git` `name` path escape (new HIGH, in scope).** `GitIndexer.clone()` builds `self._repos_dir / name` with an agent-supplied `name` (or one derived from the URL's last path component). `pathlib` discards the base when `name` is absolute (`/data/repos` / `/Users/x/private` → `/Users/x/private`), and `..` escapes it. If the path exists, `clone()` calls `update()`, which runs `git pull --ff-only` there and indexes it. So an agent can make docugraph pull and index any existing git repository on the machine. `docs/SECURITY.md` already names a path escaping the configured root as a vulnerability, so this is a patch fix (C-007).
178
+ - Rule (all callers, MCP and CLI — it is a stated guarantee, not a new policy): `name`, whether given or derived, must be one path component: non-empty, not `.` or `..`, no `/` or `\\`, no NUL, not absolute. And `(repos_dir / name).resolve()` must be inside `repos_dir.resolve()` (`is_relative_to`), which also catches a symlink planted inside `repos_dir`. Otherwise refuse **before any git process runs**.
179
+ - MCP refusal: `Refused: path_escape — <detail>`. Add `path_escape` to §3.4's reason list. CLI: a clear error and non-zero exit (a CLI call that escaped the root was never a supported behaviour).
180
+ - A URL whose derived name fails the rule (e.g. `https://host/a/..`) is refused the same way; do not silently rename it.
181
+ - New worked examples (each a test):
182
+
183
+ | # | Case | Expected | Oracle |
184
+ |---|---|---|---|
185
+ | G11 | `index_git(url=<allowed https>, name="/tmp/outside-repo")` where that path is a git repo | `Refused: path_escape`; no git process spawned; chunk count unchanged | A-1 |
186
+ | G12 | `name="../outside-repo"` | `Refused: path_escape` | A-1 |
187
+ | G13 | `name="ok-name"` | proceeds (clone args built under `repos_dir/ok-name`) | A-1 |
188
+ | G14 | a symlink `repos_dir/link -> /tmp/outside-repo`, `name="link"` | `Refused: path_escape` | A-1 |
189
+ | G15 | CLI git indexing with `name="/tmp/outside-repo"` | non-zero exit, error names the path; no git process | A-1 |
190
+ | M7 | red run: on unmodified 0.2.0, MCP `index_git` with `name` = absolute path of a temp git repo containing a sentinel string | record whether the sentinel became searchable (demonstrated or not, for the advisory) | A-1 |
191
+
192
+ **A-2 · URL policy rulings on the plan's assumptions.**
193
+ - A1, A5: approved as written.
194
+ - A2 approved, plus: an IPv4-mapped / IPv4-compatible IPv6 address is judged by its mapped IPv4 (`ipv4_mapped`), and `urlsplit(...).port` raising `ValueError` → `malformed`.
195
+ - A3 approved with one change: the browser route handler **fails closed**. Requests with scheme `http`/`https` are checked by policy; `data:`, `blob:` and `about:` pass; **every other scheme is aborted** (`file:`, `chrome-extension:`, `ws:`/`wss:`, `filesystem:`, unknown).
196
+ - A4 approved, plus these cases in §5: `http://0.0.0.0/` → `private_address`; after the scheme decision (so G2/G4 stay `scheme_not_allowed`), for allowed schemes only: an ASCII control character (incl. tab/CR/LF) anywhere in the value, or a backslash or whitespace anywhere in the **raw authority** (`urlsplit(...).netloc`, userinfo included — not the parsed hostname) → `malformed`; whitespace in a path or query is not refused (parser-differential guard: `http://127.0.0.1\\@evil.example/` must not be judged as `evil.example`); a scoped IPv6 literal (`http://[fe80::1%25eth0]/`) → refused whatever `allow_private` says.
197
+
198
+ **A-3 · Redirect enforcement (replaces the redirect clause of §3.2).** Playwright calls a `page.route` handler only for the **first** URL of a redirect chain, so a handler that only checks `request.url` lets a 302 to `169.254.169.254` through. Required design:
199
+ 1. For **navigation** requests (`request.is_navigation_request()`), the handler fetches the response itself with `route.fetch(max_redirects=0)`. If the status is 3xx, resolve `Location` against the request URL and run the policy on it: refused → `route.abort()`; allowed → `route.fulfill(response=…)` so the browser issues the next hop as a new request, which comes back through the handler.
200
+ 2. For **sub-resource** requests, check `request.url` and abort or `route.continue_()`. A sub-resource redirect is the documented residual (it cannot put a document into the index; see 3).
201
+ 3. **Backstop, independent of the browser:** after each page is crawled, run the policy on the result's final URL (`result.url` / `redirected_url`, whichever crawl4ai exposes). A page whose final URL fails policy is **dropped before indexing** and reported in the result text. This also covers anything 1 misses.
202
+ 4. Service workers: if crawl4ai lets you create the context with `service_workers="block"`, do so under policy; if not, document the residual in `docs/SECURITY.md`. WebSockets: use `page.route_web_socket` to close them under policy if the installed Playwright has it; otherwise document.
203
+ 5. **C3 adjudicates.** The local server's request log must show no request to the blocked target. Add **C5**: the redirect target is an allowed-scheme URL on a *different* blocked host reached only via a second hop (302 → 302 → blocked), same expectation. If C3/C5 cannot be made green with 1–3, stop and report; do not weaken them.
204
+ - Residual to state in `docs/SECURITY.md`: `route.fetch` resolves DNS in Playwright's process, separately from the policy's resolver, so DNS rebinding between check and fetch remains (already a non-goal).
205
+
206
+ **A-4 · git hardening addition.** Under the MCP policy, also pass `-c http.followRedirects=false`, so an HTTPS remote cannot redirect git to a host the policy never saw. Add **G16**: the built clone args under policy contain that pair before `clone`.
207
+
208
+ **A-5 · Telemetry tests.** Keep T1/T2 and add: **T3** — a call-site test that goes red if the `apply_telemetry_default(os.environ)` call is removed from `_ensure_initialized` (mutation check must show it); **T4** — a structural test (AST walk over `src/`) asserting `Graphiti(` is constructed only in `storage/graph_store.py`, so a new construction site cannot bypass the default.
209
+
210
+ **A-6 · C4 placement.** C4 needs a browser, so it is integration-marked with an explicit skip reason when Chromium is absent. Add a shell-level unit test that the MCP layer passes `max_pages ≤ 50` to the crawler and that the result text carries `max_pages capped at 50` — that one always runs.
211
+
212
+ **A-7 · Test authoring and lockfile (plan questions 1–2).** Blind authoring by a fresh subagent is approved: it reads this order, the approved signatures and `tests/` helpers, and **not** `src/`. Commit tests first and record the tests-commit SHA in the PR; the reviewer will diff test files between that SHA and the head, and any change to an approved test is a finding. `uv.lock` stays untouched (no new deps).
213
+
214
+ **A-8 · Files.** `docs/configuration.md` is added to §6 for the two new settings and the `path_escape` refusal.
215
+
216
+ **A-9 · LOW (fix if cheap, otherwise list in the PR).** Memoize DNS results per host for the duration of one crawl. Expose one public entry point (`UrlPolicy.check`); keep `check_url` private or remove it.
217
+
218
+ **Mutation checks (§8) extended:** also delete the `path_escape` check, the navigation-redirect `Location` check, the post-crawl final-URL check, and the `apply_telemetry_default` call site, one at a time, and show which tests go red.
219
+
220
+ Sources for A-3: Playwright `Page#route` docs ("The handler will only be called for the first url if the response is a redirect"), https://rubydoc.info/gems/playwright-ruby-client/0.3.0/Playwright/Page:route ; `Route#fetch` (`maxRedirects`), https://gem.sh/gems/playwright-ruby-client/v1.56.0/classes/Playwright::Route
221
+
222
+ **A-10 · Maintainer rulings after plan review (2026-11-05; binding, same force as §3–§5).**
223
+
224
+ - **N1 approved.** `src/docugraph/interfaces/cli.py` is added to §6. Only `RepoPathRefusedError` and `InvalidGitRefError` become `click.ClickException` (exit 1); every other CLI branch is unchanged. CHANGELOG lists it under Fixed.
225
+ - **N5 approved.** A scoped IPv6 literal is `malformed`, detail `"scoped IPv6 literal"`. The reason vocabulary stays closed; no new reason is added.
226
+ - **N12 approved, with two conditions.** G11–G14 inject a fake resolver returning a global address (`1.1.1.1`), so no test touches the network. For M7 the temp repo gets a local bare `origin` so 0.2.0's `git pull --ff-only` can succeed offline, and the pull outcome and the index outcome are recorded separately.
227
+ - **N8 approved.** The dropped-page line is one line, `Dropped N page(s) by policy:`, followed by the first five URLs with their reasons.
228
+ - **N6 overruled; documentation alone is not enough.** A service worker registered by an allowed page can fetch an internal address outside `page.route`, and what it fetches can be rendered into the DOM and indexed. In the same context hook that registers the route, call `context.add_init_script` (public Playwright API; it runs in every frame before page scripts) to remove `navigator.serviceWorker`. Integration test **C6** is required: an allowed local page tries to register a service worker that fetches a blocked target, and the blocked server's log must show no request. If C6 cannot go green, stop and report; do not weaken it. The `docs/SECURITY.md` note stays as defence in depth.
229
+ - **Mutation rule.** A mutation row expected to stay green on 3.11 is also run on 3.13 (the newest Python CI runs); a row green on every supported Python makes its test vacuous and is reported as such.
230
+ - **§6 additions by A-10.** `src/docugraph/interfaces/cli.py`. `docs/configuration.md` is covered by A-8.
@@ -0,0 +1,13 @@
1
+ {
2
+ "dataset": "docugraph-retrieval-bench",
3
+ "version": "0.2.0-full",
4
+ "description": "Hand-authored adversarial documentation corpus (fictional 'Quorva' SDK) with graded relevance judgments across seven retrieval failure modes. Full scale: keyword-trap distractors cover many distinct query terms (not one repeated token), semantic-distractor queries share few exact tokens with their target, and background is grown so top-10 is not near-saturated. See the benchmark README section for authoring rules.",
5
+ "counts": {
6
+ "corpus": 70,
7
+ "queries": 56
8
+ },
9
+ "sha256": {
10
+ "corpus.jsonl": "edc8be7d9aefb93cc994e1d2e4cec0d2b08d1e786ce6df26c42e645626fd859f",
11
+ "queries.jsonl": "8ad5156171fb8d91a360f888c79fdc020c24711619c774daf5f22f566d1d3fe1"
12
+ }
13
+ }