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.
- {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/ci.yml +8 -3
- docugraph-0.2.1/CHANGELOG.md +117 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/PKG-INFO +42 -1
- {docugraph-0.2.0 → docugraph-0.2.1}/README.md +41 -0
- docugraph-0.2.1/WP-docugraph-0.2.1-security.md +230 -0
- docugraph-0.2.1/benchmarks/retrieval/MANIFEST.json +13 -0
- docugraph-0.2.1/benchmarks/retrieval/baselines/bge-small-en-v1.5.json +6520 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/benchmarks/retrieval/corpus.jsonl +34 -0
- docugraph-0.2.1/benchmarks/retrieval/queries.jsonl +56 -0
- docugraph-0.2.1/docs/SECURITY.md +83 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/docs/configuration.md +43 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/pyproject.toml +1 -1
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/config.py +6 -0
- docugraph-0.2.1/src/docugraph/ingestion/crawler.py +557 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/git_indexer.py +184 -18
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/interfaces/cli.py +9 -1
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/interfaces/mcp_server.py +141 -18
- docugraph-0.2.1/src/docugraph/security/__init__.py +21 -0
- docugraph-0.2.1/src/docugraph/security/url_policy.py +280 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/graph_store.py +14 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/test_mcp_server.py +102 -0
- docugraph-0.2.1/tests/integration/test_crawl_antibot.py +156 -0
- docugraph-0.2.1/tests/integration/test_crawl_policy.py +427 -0
- docugraph-0.2.1/tests/integration/test_crawl_redirect.py +185 -0
- docugraph-0.2.1/tests/unit/test_cli_git.py +44 -0
- docugraph-0.2.1/tests/unit/test_crawl_guard.py +703 -0
- docugraph-0.2.1/tests/unit/test_git_indexer_args.py +400 -0
- docugraph-0.2.1/tests/unit/test_telemetry_default.py +133 -0
- docugraph-0.2.1/tests/unit/test_url_policy.py +418 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/uv.lock +1 -1
- docugraph-0.2.0/benchmarks/retrieval/MANIFEST.json +0 -13
- docugraph-0.2.0/benchmarks/retrieval/baselines/bge-small-en-v1.5.json +0 -2669
- docugraph-0.2.0/benchmarks/retrieval/queries.jsonl +0 -21
- docugraph-0.2.0/docs/SECURITY.md +0 -45
- docugraph-0.2.0/src/docugraph/ingestion/crawler.py +0 -305
- {docugraph-0.2.0 → docugraph-0.2.1}/.dockerignore +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.github/dependabot.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/bench.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/docker-publish.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/live-provider-smoke.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/live-reranker.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.github/workflows/release.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.gitignore +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.pre-commit-config.yaml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/.python-version +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/LICENSE +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/NOTICE +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/README.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-auto-index.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-auto-search.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-post-compact.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-pre-compact.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-rule-check.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-session-end.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/hooks/docugraph-session-start.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/install.sh +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/settings.json +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/architecture/SKILL.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/context/SKILL.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/docs/SKILL.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/index/SKILL.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/recall/SKILL.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/claude-integration/skills/remember/SKILL.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/docker/Dockerfile +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/docker/docker-compose.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/docker-compose.yml +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/docs/quickstart.md +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/examples/crawl_and_search.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/examples/index_local_repo.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/examples/mcp_client_example.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/scripts/live_provider_smoke.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/_version.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/coding_agent.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/graph_query.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/memory_ops.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/search_code.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/agents/tools/search_docs.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/embeddings.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/llm.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/core/models.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/arms.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/compare.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/dataset.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/metrics.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/evals/runner.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/api_docs.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/chunker.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/ingestion/local_files.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/interfaces/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/context_builder.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/hybrid_search.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/retrieval/reranker.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/memory_store.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/src/docugraph/storage/vector_store.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/test_cli.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/test_hooks.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/e2e/tools_snapshot.json +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/test_bench_runner.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/test_graph_store_init.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/integration/test_memory_store.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/__init__.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_bench_dataset.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_bench_metrics.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_chunker.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_cloud_provider_sdks.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_docs_consistency.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_embeddings.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_event_loop_acquisition.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_graph_embedder_adapter.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_graphiti_llm_client.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_hybrid_graph_status.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_llm_preflight.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_models.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_no_model_ids_in_source.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_reranker.py +0 -0
- {docugraph-0.2.0 → docugraph-0.2.1}/tests/unit/test_vector_store.py +0 -0
- {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
|
-
|
|
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.
|
|
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
|
+
}
|