gather-engine 2.0.0__tar.gz → 2.2.0__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 (206) hide show
  1. {gather_engine-2.0.0 → gather_engine-2.2.0}/PKG-INFO +18 -5
  2. {gather_engine-2.0.0 → gather_engine-2.2.0}/README.md +17 -4
  3. {gather_engine-2.0.0 → gather_engine-2.2.0}/pyproject.toml +1 -1
  4. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/__init__.py +1 -1
  5. gather_engine-2.2.0/src/gather/citecheck.py +119 -0
  6. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/cli.py +4 -18
  7. gather_engine-2.2.0/src/gather/cli_ext.py +19 -0
  8. gather_engine-2.2.0/src/gather/client_mcp.py +178 -0
  9. gather_engine-2.2.0/src/gather/client_network.py +109 -0
  10. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/commands.py +20 -11
  11. gather_engine-2.2.0/src/gather/docs_tool.py +93 -0
  12. gather_engine-2.2.0/src/gather/filter_ledger.py +111 -0
  13. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/flagship.py +1 -0
  14. gather_engine-2.2.0/src/gather/grant_checks.py +52 -0
  15. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/grants.py +3 -36
  16. gather_engine-2.2.0/src/gather/http_call.py +56 -0
  17. gather_engine-2.2.0/src/gather/ledger_cmd.py +39 -0
  18. gather_engine-2.2.0/src/gather/ledger_verify.py +86 -0
  19. gather_engine-2.2.0/src/gather/local_model.py +83 -0
  20. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/mcp.py +17 -45
  21. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/method.py +2 -0
  22. gather_engine-2.2.0/src/gather/reddit.py +203 -0
  23. gather_engine-2.2.0/src/gather/reddit_cmd.py +34 -0
  24. gather_engine-2.2.0/src/gather/reddit_items.py +64 -0
  25. gather_engine-2.2.0/src/gather/report.py +72 -0
  26. gather_engine-2.2.0/src/gather/report_cmd.py +81 -0
  27. gather_engine-2.2.0/src/gather/route.py +78 -0
  28. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/run_config.py +36 -4
  29. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/source.py +16 -9
  30. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/video_cmd.py +3 -3
  31. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/video_source.py +5 -0
  32. gather_engine-2.2.0/src/gather/youtube_api.py +129 -0
  33. gather_engine-2.2.0/src/gather/youtube_cmd.py +90 -0
  34. gather_engine-2.2.0/src/gather/youtube_probe.py +92 -0
  35. gather_engine-2.2.0/src/gather/youtube_route.py +166 -0
  36. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/PKG-INFO +18 -5
  37. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/SOURCES.txt +39 -0
  38. gather_engine-2.2.0/tests/test_citecheck.py +72 -0
  39. gather_engine-2.2.0/tests/test_client_disclosure.py +80 -0
  40. gather_engine-2.2.0/tests/test_client_network.py +153 -0
  41. gather_engine-2.2.0/tests/test_client_package.py +138 -0
  42. gather_engine-2.2.0/tests/test_client_profile.py +70 -0
  43. gather_engine-2.2.0/tests/test_client_setup.py +79 -0
  44. gather_engine-2.2.0/tests/test_client_vendored.py +144 -0
  45. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_docs.py +1 -1
  46. gather_engine-2.2.0/tests/test_filter_ledger.py +117 -0
  47. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_flagship_cli.py +2 -1
  48. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_grants.py +1 -1
  49. gather_engine-2.2.0/tests/test_http_call.py +59 -0
  50. gather_engine-2.2.0/tests/test_mcp_annotations.py +38 -0
  51. gather_engine-2.2.0/tests/test_reddit.py +180 -0
  52. gather_engine-2.2.0/tests/test_reddit_grants.py +29 -0
  53. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_release_workflow.py +6 -1
  54. gather_engine-2.2.0/tests/test_report.py +99 -0
  55. gather_engine-2.2.0/tests/test_route.py +40 -0
  56. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_video_source.py +3 -2
  57. gather_engine-2.2.0/tests/test_youtube_api.py +63 -0
  58. gather_engine-2.2.0/tests/test_youtube_grants.py +30 -0
  59. gather_engine-2.2.0/tests/test_youtube_probe.py +88 -0
  60. gather_engine-2.2.0/tests/test_youtube_route.py +188 -0
  61. {gather_engine-2.0.0 → gather_engine-2.2.0}/LICENSE +0 -0
  62. {gather_engine-2.0.0 → gather_engine-2.2.0}/setup.cfg +0 -0
  63. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/__main__.py +0 -0
  64. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/_vendor/__init__.py +0 -0
  65. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/_vendor/safe_spawn.py +0 -0
  66. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/api.py +0 -0
  67. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/arxiv.py +0 -0
  68. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/availability.py +0 -0
  69. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/backends.py +0 -0
  70. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/backends_browser.py +0 -0
  71. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/benchmark.py +0 -0
  72. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/browser.py +0 -0
  73. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/browser_evidence.py +0 -0
  74. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/cache.py +0 -0
  75. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/captions.py +0 -0
  76. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/channel.py +0 -0
  77. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/channel_ledger.py +0 -0
  78. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/context.py +0 -0
  79. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/corpus_cmd.py +0 -0
  80. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/crawl.py +0 -0
  81. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/credentials.py +0 -0
  82. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/derive.py +0 -0
  83. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/digest.py +0 -0
  84. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/docs.py +0 -0
  85. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/dom.py +0 -0
  86. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/export.py +0 -0
  87. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/extract.py +0 -0
  88. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/fastparse.py +0 -0
  89. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/federation.py +0 -0
  90. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/federation_cmd.py +0 -0
  91. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/federation_policy.py +0 -0
  92. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/federation_receipt.py +0 -0
  93. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/feed.py +0 -0
  94. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/fetch.py +0 -0
  95. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/interop.py +0 -0
  96. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/item.py +0 -0
  97. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/localpath.py +0 -0
  98. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/model.py +0 -0
  99. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/monitor.py +0 -0
  100. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/net.py +0 -0
  101. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/ocr.py +0 -0
  102. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pacing.py +0 -0
  103. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/payloads.py +0 -0
  104. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pdf.py +0 -0
  105. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pilot.py +0 -0
  106. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pilot_bundle.py +0 -0
  107. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pilot_commands.py +0 -0
  108. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pilot_manifest.py +0 -0
  109. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pilot_report.py +0 -0
  110. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/pilot_sources.py +0 -0
  111. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/provenance.py +0 -0
  112. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/recall.py +0 -0
  113. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/run.py +0 -0
  114. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/schema_extract.py +0 -0
  115. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/scholar.py +0 -0
  116. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/scope.py +0 -0
  117. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/search.py +0 -0
  118. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/spawn.py +0 -0
  119. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/store.py +0 -0
  120. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/stream.py +0 -0
  121. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/track.py +0 -0
  122. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/transcribe.py +0 -0
  123. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/video.py +0 -0
  124. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/web.py +0 -0
  125. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/web_commands.py +0 -0
  126. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather/ytdlp.py +0 -0
  127. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/dependency_links.txt +0 -0
  128. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/entry_points.txt +0 -0
  129. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/requires.txt +0 -0
  130. {gather_engine-2.0.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/top_level.txt +0 -0
  131. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_api.py +0 -0
  132. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_arxiv.py +0 -0
  133. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_availability.py +0 -0
  134. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_backends.py +0 -0
  135. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_backends_browser.py +0 -0
  136. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_backends_card.py +0 -0
  137. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_benchmark.py +0 -0
  138. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_browser_evidence.py +0 -0
  139. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_cache.py +0 -0
  140. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_captions.py +0 -0
  141. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_channel.py +0 -0
  142. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_channel_ledger.py +0 -0
  143. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_cli.py +0 -0
  144. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_cli_web.py +0 -0
  145. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_context.py +0 -0
  146. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_context_mountinfo.py +0 -0
  147. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_crawl.py +0 -0
  148. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_credentials.py +0 -0
  149. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_demo.py +0 -0
  150. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_derive.py +0 -0
  151. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_device_names.py +0 -0
  152. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_digest.py +0 -0
  153. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_dom.py +0 -0
  154. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_export.py +0 -0
  155. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_extract.py +0 -0
  156. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_fastparse.py +0 -0
  157. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_federation.py +0 -0
  158. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_federation_cli.py +0 -0
  159. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_federation_receipt.py +0 -0
  160. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_feed.py +0 -0
  161. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_fetch.py +0 -0
  162. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_hard_sources.py +0 -0
  163. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_interop.py +0 -0
  164. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_item.py +0 -0
  165. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_localpath.py +0 -0
  166. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_mcp.py +0 -0
  167. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_method.py +0 -0
  168. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_model.py +0 -0
  169. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_monitor.py +0 -0
  170. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_net.py +0 -0
  171. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_nonlocal_paths.py +0 -0
  172. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pacing.py +0 -0
  173. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_package.py +0 -0
  174. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pdf.py +0 -0
  175. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot.py +0 -0
  176. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_bundle.py +0 -0
  177. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_cli.py +0 -0
  178. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_manifest.py +0 -0
  179. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_refresh.py +0 -0
  180. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_report.py +0 -0
  181. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_sample.py +0 -0
  182. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_showcase.py +0 -0
  183. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_pilot_sources.py +0 -0
  184. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_provenance.py +0 -0
  185. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_recall.py +0 -0
  186. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_repo_art.py +0 -0
  187. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_repo_card.py +0 -0
  188. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_run.py +0 -0
  189. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_schema_extract.py +0 -0
  190. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_scholar.py +0 -0
  191. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_scope.py +0 -0
  192. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_search.py +0 -0
  193. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_spawn_children.py +0 -0
  194. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_spawn_working_folder.py +0 -0
  195. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_store.py +0 -0
  196. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_stream.py +0 -0
  197. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_track.py +0 -0
  198. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_vendored.py +0 -0
  199. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_verify_pilot_cli.py +0 -0
  200. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_video.py +0 -0
  201. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_video_spawn.py +0 -0
  202. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_web.py +0 -0
  203. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_web_refine.py +0 -0
  204. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_xml_safety.py +0 -0
  205. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_ytdlp.py +0 -0
  206. {gather_engine-2.0.0 → gather_engine-2.2.0}/tests/test_ytdlp_real.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gather-engine
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Accountable research intake for difficult sources, with provenance receipts and witnessed digests.
5
5
  Author: Zain Dana Harper
6
6
  License-Expression: LicenseRef-FSL-1.1-MIT
@@ -34,7 +34,7 @@ Dynamic: license-file
34
34
  [![downloads](https://img.shields.io/pypi/dm/gather-engine?label=downloads&style=flat-square&labelColor=14041b)](https://pypi.org/project/gather-engine/)
35
35
  ![python: 3.11+](https://img.shields.io/badge/python-3.11%2B-blue?style=flat-square&labelColor=14041b)
36
36
  ![deps: none (core)](https://img.shields.io/badge/core%20deps-none-success?style=flat-square&labelColor=14041b)
37
- ![version: 2.0.0](https://img.shields.io/badge/version-2.0.0-26dfe8?style=flat-square&labelColor=14041b)
37
+ ![version: 2.2.0](https://img.shields.io/badge/version-2.2.0-26dfe8?style=flat-square&labelColor=14041b)
38
38
 
39
39
  gather pulls research out of the places most tools break on: arXiv papers, authenticated JSON APIs, JavaScript-rendered pages via a real headless browser, scanned images through OCR, and audio through transcription, alongside video, web, feeds, and local docs. The core runs with zero third-party runtime dependencies, and the same engine is reachable from the CLI, MCP tools, and plain Python. Every run writes a receipt you can re-check.
40
40
 
@@ -42,7 +42,7 @@ gather pulls research out of the places most tools break on: arXiv papers, authe
42
42
 
43
43
  ## Current status
44
44
 
45
- `gather-engine 2.0.0` is the current source version. Core intake,
45
+ `gather-engine 2.2.0` is the current source version. Core intake,
46
46
  content-addressed corpus storage, exact UTF-8 source-byte receipts, readable
47
47
  context selection, descriptor handoff, pilot monitoring, CLI, Python API, and MCP
48
48
  surfaces are present in this checkout. Browser rendering, OCR, audio, and fast
@@ -82,7 +82,9 @@ fabricated field cannot reach the corpus by being plausible.
82
82
  - **Track elements across redesigns.** Fingerprint a scraped element once, then relocate it in any later version of the page and get a typed MATCH, RELOCATED, DRIFT, or GONE verdict (`gather.track`, Python API).
83
83
  - **Structured extraction with a hallucination check.** `gather.schema_extract` binds schema fields to source nodes, and `verify_record` rejects any LLM-proposed field value not grounded in the fetched content.
84
84
  - **Streaming extraction.** `gather.stream` parses an HTML stream chunk by chunk and emits partial-update commits as blocks complete, each folded into a hash chain, so a streamed extraction is replayable.
85
- - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp`), static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
85
+ - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
86
+ - **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify.
87
+ - **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
86
88
  - **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
87
89
  - **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
88
90
  - **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
@@ -250,4 +252,15 @@ Keep the README, package metadata, and examples aligned with current behavior be
250
252
 
251
253
  ---
252
254
 
253
- **[Zentropy Labs](https://github.com/ZentropyLabs-ai)** · order out of entropy. An independent lab building evidence-first tools that leave a re-checkable artifact behind. Built by Zain Dana Harper in Seattle. The full workbench is at [Project Telos](https://harperz9.github.io).
255
+ Built by **[Zain Dana Harper](https://harperz9.github.io)** in Seattle: evidence-first tools that leave a re-checkable artifact behind. The full workbench is at [Project Telos](https://harperz9.github.io).
256
+
257
+ ## Local client packages
258
+
259
+ The optional client bundle adds a bounded read-only MCP profile with an explicit
260
+ workspace selected at launch. See [client package setup](client-plugin/README.md).
261
+ Network retrieval is disabled until an exact origin is granted at launch.
262
+ Allowed GET requests return source text and byte-hash receipts; redirects,
263
+ ambient proxies, credentials and process execution remain disabled.
264
+ Windows x64 MCPB and ZIP bundles include their runtime; source plugins require
265
+ Python 3.11+. The full CLI/MCP retains advanced operations. Client-specific
266
+ installation and marketplace acceptance require separate verification.
@@ -8,7 +8,7 @@
8
8
  [![downloads](https://img.shields.io/pypi/dm/gather-engine?label=downloads&style=flat-square&labelColor=14041b)](https://pypi.org/project/gather-engine/)
9
9
  ![python: 3.11+](https://img.shields.io/badge/python-3.11%2B-blue?style=flat-square&labelColor=14041b)
10
10
  ![deps: none (core)](https://img.shields.io/badge/core%20deps-none-success?style=flat-square&labelColor=14041b)
11
- ![version: 2.0.0](https://img.shields.io/badge/version-2.0.0-26dfe8?style=flat-square&labelColor=14041b)
11
+ ![version: 2.2.0](https://img.shields.io/badge/version-2.2.0-26dfe8?style=flat-square&labelColor=14041b)
12
12
 
13
13
  gather pulls research out of the places most tools break on: arXiv papers, authenticated JSON APIs, JavaScript-rendered pages via a real headless browser, scanned images through OCR, and audio through transcription, alongside video, web, feeds, and local docs. The core runs with zero third-party runtime dependencies, and the same engine is reachable from the CLI, MCP tools, and plain Python. Every run writes a receipt you can re-check.
14
14
 
@@ -16,7 +16,7 @@ gather pulls research out of the places most tools break on: arXiv papers, authe
16
16
 
17
17
  ## Current status
18
18
 
19
- `gather-engine 2.0.0` is the current source version. Core intake,
19
+ `gather-engine 2.2.0` is the current source version. Core intake,
20
20
  content-addressed corpus storage, exact UTF-8 source-byte receipts, readable
21
21
  context selection, descriptor handoff, pilot monitoring, CLI, Python API, and MCP
22
22
  surfaces are present in this checkout. Browser rendering, OCR, audio, and fast
@@ -56,7 +56,9 @@ fabricated field cannot reach the corpus by being plausible.
56
56
  - **Track elements across redesigns.** Fingerprint a scraped element once, then relocate it in any later version of the page and get a typed MATCH, RELOCATED, DRIFT, or GONE verdict (`gather.track`, Python API).
57
57
  - **Structured extraction with a hallucination check.** `gather.schema_extract` binds schema fields to source nodes, and `verify_record` rejects any LLM-proposed field value not grounded in the fetched content.
58
58
  - **Streaming extraction.** `gather.stream` parses an HTML stream chunk by chunk and emits partial-update commits as blocks complete, each folded into a hash chain, so a streamed extraction is replayable.
59
- - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp`), static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
59
+ - **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
60
+ - **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify.
61
+ - **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
60
62
  - **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
61
63
  - **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
62
64
  - **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
@@ -224,4 +226,15 @@ Keep the README, package metadata, and examples aligned with current behavior be
224
226
 
225
227
  ---
226
228
 
227
- **[Zentropy Labs](https://github.com/ZentropyLabs-ai)** · order out of entropy. An independent lab building evidence-first tools that leave a re-checkable artifact behind. Built by Zain Dana Harper in Seattle. The full workbench is at [Project Telos](https://harperz9.github.io).
229
+ Built by **[Zain Dana Harper](https://harperz9.github.io)** in Seattle: evidence-first tools that leave a re-checkable artifact behind. The full workbench is at [Project Telos](https://harperz9.github.io).
230
+
231
+ ## Local client packages
232
+
233
+ The optional client bundle adds a bounded read-only MCP profile with an explicit
234
+ workspace selected at launch. See [client package setup](client-plugin/README.md).
235
+ Network retrieval is disabled until an exact origin is granted at launch.
236
+ Allowed GET requests return source text and byte-hash receipts; redirects,
237
+ ambient proxies, credentials and process execution remain disabled.
238
+ Windows x64 MCPB and ZIP bundles include their runtime; source plugins require
239
+ Python 3.11+. The full CLI/MCP retains advanced operations. Client-specific
240
+ installation and marketplace acceptance require separate verification.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gather-engine"
7
- version = "2.0.0"
7
+ version = "2.2.0"
8
8
  description = "Accountable research intake for difficult sources, with provenance receipts and witnessed digests."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -28,7 +28,7 @@ from gather.scope import filter_scope, in_scope
28
28
  from gather.source import Catalog, Source
29
29
  from gather.store import Corpus
30
30
 
31
- __version__ = "2.0.0"
31
+ __version__ = "2.2.0"
32
32
 
33
33
  __all__ = [
34
34
  "Catalog", "Corpus", "CorpusRootDescriptor", "CorpusRootIdentity", "Digest", "Item", "NullProvenanceProvider", "NullSynthesizer",
@@ -0,0 +1,119 @@
1
+ """A code check on every citation in a report. Pure: no network, no model.
2
+
3
+ A report cites an excerpt by quoting it and naming its number: ``"the exact words" [2]``. For
4
+ each citation the check decides, by string comparison alone:
5
+
6
+ - ``verified``: the quoted span appears in excerpt 2 (after Unicode, quote-mark, dash and
7
+ whitespace normalisation, and case folding);
8
+ - ``not-in-source``: excerpt 2 exists and does not contain the span;
9
+ - ``unknown-source``: there is no excerpt 2;
10
+ - ``too-short``: the quote has fewer than ``MIN_QUOTE_WORDS`` words, so a match would prove
11
+ little; it is not counted as verified;
12
+ - ``unchecked``: a bracketed number with no quote before it, so there is nothing to compare.
13
+
14
+ Sentences with no citation at all are listed as uncited. Nothing is hidden: every citation and
15
+ every uncited sentence is in the result. Precision is verified citations over all citations.
16
+
17
+ What this does not check: whether the sentence around a verified quote says what the excerpt
18
+ means. A quote can be exact and the claim around it can still stretch it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import unicodedata
25
+ from dataclasses import dataclass
26
+
27
+ CHECK_SCHEMA = "gather.citation-check/1"
28
+ MIN_QUOTE_WORDS = 4
29
+ STATUSES = ("verified", "not-in-source", "unknown-source", "too-short", "unchecked")
30
+
31
+ _MARKS = str.maketrans({"“": '"', "”": '"', "„": '"', "«": '"', "»": '"',
32
+ "‘": "'", "’": "'", "–": "-", "—": "-", "−": "-",
33
+ " ": " "})
34
+ # a double-quoted span, optional space, then [n]
35
+ _QUOTE_CITE = re.compile(r'"([^"]{1,2000})"\s*\[(\d{1,4})\]')
36
+ _BARE_CITE = re.compile(r"\[(\d{1,4})\]")
37
+ _LIST_MARK = re.compile(r"^(?:[-*+]|\d{1,3}[.)])\s+")
38
+ _SENTENCE = re.compile(r'(?<=[.!?])["\')\]]*\s+(?=["(\[A-Z0-9])')
39
+
40
+
41
+ def normalize(text: str) -> str:
42
+ """NFKC, straight quote marks and hyphens, collapsed whitespace, case folded."""
43
+ text = unicodedata.normalize("NFKC", text).translate(_MARKS)
44
+ return re.sub(r"\s+", " ", text).strip().casefold()
45
+
46
+
47
+ def _strip_edges(quote: str) -> str:
48
+ return normalize(quote).strip(" .,;:!?'")
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class Citation:
53
+ sentence: int
54
+ source: int
55
+ quote: str | None
56
+ status: str
57
+
58
+ def to_dict(self) -> dict:
59
+ return {"sentence": self.sentence, "source": self.source, "quote": self.quote, "status": self.status}
60
+
61
+
62
+ def _split_line(line: str) -> list[str]:
63
+ """Split one line at sentence ends that fall outside a double-quoted span."""
64
+ parts, start = [], 0
65
+ for m in _SENTENCE.finditer(line):
66
+ if line[:m.start()].count('"') % 2 == 0:
67
+ parts.append(line[start:m.start() + len(m.group(0).rstrip())].strip())
68
+ start = m.end()
69
+ parts.append(line[start:].strip())
70
+ return [p for p in parts if p]
71
+
72
+
73
+ def sentences(report: str) -> list[str]:
74
+ """The report's sentences: lines split at sentence ends outside quotes, list markers removed,
75
+ headings and blank lines dropped."""
76
+ out: list[str] = []
77
+ for raw in report.splitlines():
78
+ line = raw.strip()
79
+ if not line or line.startswith("#"):
80
+ continue
81
+ out.extend(_split_line(_LIST_MARK.sub("", line.translate(_MARKS))))
82
+ return out
83
+
84
+
85
+ def _status(quote: str, source: int, excerpts: list[str]) -> str:
86
+ if not 1 <= source <= len(excerpts):
87
+ return "unknown-source"
88
+ span = _strip_edges(quote)
89
+ if len(span.split()) < MIN_QUOTE_WORDS:
90
+ return "too-short"
91
+ return "verified" if span in normalize(excerpts[source - 1]) else "not-in-source"
92
+
93
+
94
+ def _sentence_citations(n: int, sentence: str, excerpts: list[str]) -> list[Citation]:
95
+ found = [Citation(n, int(m.group(2)), m.group(1), _status(m.group(1), int(m.group(2)), excerpts))
96
+ for m in _QUOTE_CITE.finditer(sentence.translate(_MARKS))]
97
+ bare_text = _QUOTE_CITE.sub("", sentence.translate(_MARKS))
98
+ found += [Citation(n, int(m.group(1)), None, "unchecked") for m in _BARE_CITE.finditer(bare_text)]
99
+ return found
100
+
101
+
102
+ def check_report(report: str, excerpts: list[str]) -> dict:
103
+ """Check every citation in ``report`` against ``excerpts`` (excerpt 1 is ``excerpts[0]``)."""
104
+ cites: list[Citation] = []
105
+ uncited: list[str] = []
106
+ for n, sentence in enumerate(sentences(report)):
107
+ found = _sentence_citations(n, sentence, excerpts)
108
+ cites.extend(found)
109
+ if not found:
110
+ uncited.append(sentence)
111
+ counts = {s: sum(1 for c in cites if c.status == s) for s in STATUSES}
112
+ total = len(cites)
113
+ return {
114
+ "schema": CHECK_SCHEMA, "excerpts": len(excerpts), "citations": [c.to_dict() for c in cites],
115
+ "counts": counts, "total": total,
116
+ "precision": round(counts["verified"] / total, 4) if total else None,
117
+ "uncited_sentences": uncited,
118
+ "does_not_prove": "that the claim around a verified quote matches the excerpt's meaning",
119
+ }
@@ -4,6 +4,7 @@ import argparse
4
4
  import sys
5
5
 
6
6
  from gather import __version__
7
+ from gather.cli_ext import add_extension_parsers
7
8
  from gather.commands import (
8
9
  cmd_api,
9
10
  cmd_arxiv,
@@ -11,12 +12,10 @@ from gather.commands import (
11
12
  cmd_docs,
12
13
  cmd_feed,
13
14
  cmd_ocr,
14
- cmd_parse,
15
15
  cmd_pdf,
16
16
  cmd_run,
17
17
  cmd_scholar,
18
18
  cmd_transcribe,
19
- cmd_video,
20
19
  cmd_web,
21
20
  )
22
21
  from gather.corpus_cmd import cmd_corpus
@@ -29,7 +28,6 @@ from gather.pilot_commands import (
29
28
  cmd_pilot_run,
30
29
  cmd_pilot_verify,
31
30
  )
32
- from gather.video_cmd import add_channel_parser, add_ytdlp_options
33
31
  from gather.web_commands import (
34
32
  cmd_caps,
35
33
  cmd_crawl,
@@ -50,6 +48,8 @@ def _add_common(p: argparse.ArgumentParser) -> None:
50
48
  p.add_argument("--scope", default=None, help="comma-separated scope terms; keep items mentioning any")
51
49
  p.add_argument("--json", action="store_true", help="emit the catalog and digest as JSON")
52
50
  p.add_argument("--store", default=None, metavar="DIR", help="persist gathered items into a corpus at DIR")
51
+ p.add_argument("--ledger", default=None, metavar="DIR",
52
+ help="write a filter ledger (ledger.json, input.jsonl) of what --scope dropped into DIR")
53
53
 
54
54
 
55
55
  def _add_flagship_commands(sub) -> None:
@@ -124,21 +124,7 @@ def build_parser() -> argparse.ArgumentParser:
124
124
  sub = parser.add_subparsers(dest="command")
125
125
  _add_flagship_commands(sub)
126
126
 
127
- parse = sub.add_parser("parse", help="parse a saved yt-dlp info.json (+ optional .vtt), offline, no network")
128
- parse.add_argument("info", help="path to a yt-dlp info.json")
129
- parse.add_argument("--vtt", default=None, help="path to a .vtt captions file")
130
- parse.add_argument("--auto-captions", action="store_true",
131
- help="captions are machine-generated: collapse rolling-window growth, stamp auto-caption")
132
- _add_common(parse)
133
- parse.set_defaults(func=cmd_parse)
134
-
135
- video = sub.add_parser("video", help="fetch a video via yt-dlp (needs yt-dlp on PATH and network)")
136
- video.add_argument("url")
137
- video.add_argument("--comments", action="store_true", help="also gather comments")
138
- add_ytdlp_options(video)
139
- _add_common(video)
140
- video.set_defaults(func=cmd_video)
141
- add_channel_parser(sub)
127
+ add_extension_parsers(sub, _add_common)
142
128
 
143
129
  web = sub.add_parser("web", help="fetch a static web page via http(s) and extract readable text")
144
130
  web.add_argument("url")
@@ -0,0 +1,19 @@
1
+ """The subcommands that live in their own modules, registered in one place for ``gather.cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+
8
+ def add_extension_parsers(sub, add_common: Callable) -> None:
9
+ from gather.ledger_cmd import add_ledger_parser
10
+ from gather.reddit_cmd import add_reddit_parser
11
+ from gather.report_cmd import add_report_parsers
12
+ from gather.video_cmd import add_channel_parser
13
+ from gather.youtube_cmd import add_video_parsers
14
+
15
+ add_video_parsers(sub, add_common)
16
+ add_channel_parser(sub)
17
+ add_reddit_parser(sub, add_common)
18
+ add_report_parsers(sub, add_common)
19
+ add_ledger_parser(sub)
@@ -0,0 +1,178 @@
1
+ """Local client profile: launch-root confinement and explicit tool allowlist.
2
+
3
+ This is a read-only convenience boundary, not an OS sandbox. Concurrent local
4
+ filesystem mutation is outside its threat model. Full CLI/MCP remains separate.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import stat
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ MAX_FILE = 8_000_000
16
+ MAX_ENTRIES = 20_000
17
+ NAME = "gather"
18
+ PACKAGE = "gather"
19
+
20
+
21
+ class ClientRefusal(ValueError):
22
+ def __init__(self, detail, code="PATH_DENIED"):
23
+ super().__init__(detail)
24
+ self.code = code
25
+
26
+
27
+ def confined(root: Path, value: object, *, tree=False) -> Path:
28
+ if not isinstance(value, str) or not value or chr(0) in value:
29
+ raise ClientRefusal("path must be a non-empty string")
30
+ # Reject Windows network/device/ADS paths before resolving or accessing them.
31
+ text = value.replace(chr(92), "/")
32
+ if text.startswith("//") or ":" in text[2:] or (":" in text and not (len(text) > 2 and text[1:3] == ":/")):
33
+ raise ClientRefusal("network, device and alternate-stream paths are not permitted")
34
+ candidate = Path(value)
35
+ if not candidate.is_absolute():
36
+ candidate = root / candidate
37
+ path = candidate.resolve(strict=True)
38
+ if path != root and root not in path.parents:
39
+ raise ClientRefusal("path is outside the launch workspace")
40
+ for part in [candidate, *candidate.parents]:
41
+ if part == root.parent:
42
+ break
43
+ info = part.lstat()
44
+ if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400:
45
+ raise ClientRefusal("links and reparse points are not permitted")
46
+ if path.is_file() and path.stat().st_size > MAX_FILE:
47
+ raise ClientRefusal("file exceeds local client limit")
48
+ if tree and path.is_dir():
49
+ count = 0
50
+ for folder, dirs, files in os.walk(path, followlinks=False):
51
+ dirs[:] = [d for d in dirs if d not in {".git", ".venv", "node_modules", "__pycache__"}]
52
+ for name in [*dirs, *files]:
53
+ count += 1
54
+ if count > MAX_ENTRIES:
55
+ raise ClientRefusal("workspace exceeds local client entry limit")
56
+ confined(root, str(Path(folder) / name))
57
+ return path
58
+
59
+
60
+ def handle(req, root, network=None):
61
+ if not isinstance(req, dict):
62
+ return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request"}}
63
+ if "id" not in req:
64
+ return None
65
+ response = {"jsonrpc": "2.0", "id": req["id"]}
66
+ method = req.get("method")
67
+ if method == "initialize":
68
+ from gather import __version__
69
+ response["result"] = {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
70
+ "serverInfo": {"name": NAME + "-local", "version": __version__}}
71
+ elif method == "ping":
72
+ response["result"] = {}
73
+ elif method == "tools/list":
74
+ response["result"] = {"tools": definitions(network)}
75
+ elif method == "tools/call":
76
+ try:
77
+ params = req.get("params") or {}
78
+ args = params.get("arguments") or {}
79
+ name = params.get("name")
80
+ definition = next((d for d in definitions(network) if d["name"] == name), None)
81
+ if definition is None:
82
+ raise ClientRefusal("tool requires the separately configured full MCP surface", "TOOL_NOT_GRANTED")
83
+ if not isinstance(args, dict) or set(args) - set(definition["inputSchema"]["properties"]):
84
+ raise ClientRefusal("unsupported arguments cannot grant permissions", "ARGUMENTS_DENIED")
85
+ missing = set(definition["inputSchema"].get("required", [])) - set(args)
86
+ if missing:
87
+ raise ClientRefusal("missing required arguments", "ARGUMENTS_DENIED")
88
+ data = network.get(args["url"]) if name == "gather.fetch" else invoke(name, dict(args), root)
89
+ response["result"] = {"content": [{"type": "text", "text": data}], "isError": False}
90
+ except Exception as exc:
91
+ response["result"] = {"content": [{"type": "text", "text": json.dumps(
92
+ {"code": exc.code if isinstance(exc, ClientRefusal) else "LOCAL_PROFILE_ERROR",
93
+ "detail": str(exc)})}], "isError": True}
94
+ else:
95
+ response["error"] = {"code": -32601, "message": "method not found"}
96
+ return response
97
+
98
+
99
+ def install_process_boundary(network=None):
100
+ """Deny process and network actions in this stdlib profile, including Git."""
101
+ def audit(event, args):
102
+ if event.startswith("socket."):
103
+ if network is None:
104
+ raise PermissionError("local client profile does not grant network")
105
+ network.audit_socket(event, args)
106
+ if event in {"subprocess.Popen", "os.system", "os.posix_spawn", "os.posix_spawnp",
107
+ "os.exec", "os.spawn"}:
108
+ raise PermissionError("local client profile does not grant processes or network")
109
+ sys.addaudithook(audit)
110
+
111
+
112
+ def origin_array(value):
113
+ """Parse desktop setup text without interpreting it as flags or authority."""
114
+ if not value.strip():
115
+ return []
116
+ try:
117
+ values = json.loads(value)
118
+ except json.JSONDecodeError as exc:
119
+ raise argparse.ArgumentTypeError("origin setting must be a JSON array of strings") from exc
120
+ if not isinstance(values, list) or any(not isinstance(entry, str) for entry in values):
121
+ raise argparse.ArgumentTypeError("origin setting must be a JSON array of strings")
122
+ return values
123
+
124
+
125
+ def main(argv=None):
126
+ parser = argparse.ArgumentParser(description=__doc__)
127
+ parser.add_argument("--workspace", required=True, help="explicit local directory this client may read")
128
+ parser.add_argument("--allow-origin", action="append", default=[], help="allow GET from this exact public HTTPS origin")
129
+ parser.add_argument("--allow-loopback-origin", action="append", default=[], help="allow GET from this exact literal loopback HTTP(S) origin")
130
+ parser.add_argument("--allow-origins-json", type=origin_array, default=[], help="desktop setup JSON array of public HTTPS origins; empty means none")
131
+ parser.add_argument("--allow-loopback-origins-json", type=origin_array, default=[], help="desktop setup JSON array of literal loopback origins; empty means none")
132
+ args = parser.parse_args(argv)
133
+ root_arg = Path(args.workspace).absolute()
134
+ root = confined(root_arg, str(root_arg))
135
+ if not root.is_dir():
136
+ parser.error("workspace must be a directory")
137
+ from gather.client_network import NetworkGrant
138
+ try:
139
+ network = NetworkGrant.from_launch([*args.allow_origin, *args.allow_origins_json],
140
+ [*args.allow_loopback_origin, *args.allow_loopback_origins_json])
141
+ except (ValueError, OSError) as exc:
142
+ parser.error(str(exc))
143
+ install_process_boundary(network)
144
+ # The local profile does not read permission grants from the environment.
145
+ for line in sys.stdin:
146
+ if len(line) > MAX_FILE:
147
+ return 2
148
+ try:
149
+ response = handle(json.loads(line), root, network)
150
+ except json.JSONDecodeError:
151
+ response = {"jsonrpc": "2.0", "id": None,
152
+ "error": {"code": -32700, "message": "parse error"}}
153
+ if response is not None:
154
+ sys.stdout.write(json.dumps(response) + "\n")
155
+ sys.stdout.flush()
156
+ return 0
157
+
158
+ def definitions(network=None):
159
+ from gather.docs_tool import annotate, docs_tool
160
+ tools = [annotate(docs_tool())]
161
+ if network is not None and network.origins:
162
+ tools.append(annotate({"name": "gather.fetch", "description": "Read an allowed origin; return untrusted source text and a byte receipt. No redirects or credentials.",
163
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"], "additionalProperties": False}}))
164
+ return tools
165
+
166
+
167
+ def invoke(name, args, root):
168
+ from gather.docs_tool import call_docs
169
+ # Single files avoid a directory walk following a file link outside the grant.
170
+ path = confined(root, args["path"])
171
+ if not path.is_file():
172
+ raise ClientRefusal("local client intake requires one text file per call")
173
+ args["path"] = str(path)
174
+ return call_docs(args)
175
+
176
+
177
+ if __name__ == "__main__":
178
+ raise SystemExit(main())
@@ -0,0 +1,109 @@
1
+ """Launch-only, exact-origin GET grants for the read-only client profile."""
2
+ from __future__ import annotations
3
+
4
+ import ipaddress
5
+ import json
6
+ import socket
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from time import monotonic
12
+
13
+ from gather.fetch import RawResponse, fetch
14
+ from gather.grants import Grants
15
+ from gather.net import decode_body, validate_public_http_url
16
+
17
+
18
+ def origin_of(url):
19
+ if not isinstance(url, str) or any(ord(c) < 33 for c in url) or "\\" in url:
20
+ raise ValueError("URL must be an explicit HTTP(S) URL without whitespace")
21
+ parsed = urllib.parse.urlsplit(url)
22
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username is not None or parsed.password is not None:
23
+ raise ValueError("URL credentials and non-HTTP(S) schemes are not allowed")
24
+ return parsed, (parsed.scheme, parsed.hostname.lower(), parsed.port or (443 if parsed.scheme == "https" else 80))
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class NetworkGrant:
29
+ origins: frozenset = frozenset()
30
+ addresses: frozenset = frozenset()
31
+
32
+ @classmethod
33
+ def from_launch(cls, public=(), loopback=()):
34
+ origins, addresses = set(), set()
35
+ for entries, local in ((public, False), (loopback, True)):
36
+ for url in entries:
37
+ parsed, origin = origin_of(url)
38
+ if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
39
+ raise ValueError("launch grants must name an origin without a path, query or fragment")
40
+ if local:
41
+ if not ipaddress.ip_address(origin[1]).is_loopback:
42
+ raise ValueError("loopback grants require a literal loopback IP address")
43
+ else:
44
+ if origin[0] != "https":
45
+ raise ValueError("public origins require HTTPS")
46
+ validate_public_http_url(url)
47
+ infos = socket.getaddrinfo(origin[1], origin[2], type=socket.SOCK_STREAM)
48
+ for info in infos:
49
+ address = ipaddress.ip_address(info[4][0])
50
+ if not local and not address.is_global:
51
+ raise ValueError("public origin must resolve only to public addresses")
52
+ addresses.add((str(address), origin[2]))
53
+ origins.add(origin)
54
+ return cls(frozenset(origins), frozenset(addresses))
55
+
56
+ def audit_socket(self, event, args):
57
+ if not self.origins:
58
+ raise PermissionError("network requires a launch grant")
59
+ if event == "socket.__new__":
60
+ if args[1] in {socket.AF_INET, socket.AF_INET6} and args[2] == socket.SOCK_STREAM:
61
+ return
62
+ elif event == "socket.getaddrinfo":
63
+ if any(args[0] == host and args[1] == port for _, host, port in self.origins):
64
+ return
65
+ elif event == "socket.connect":
66
+ destination = args[1]
67
+ if isinstance(destination, tuple) and (destination[0], destination[1]) in self.addresses:
68
+ return
69
+ raise PermissionError("socket operation is outside the launch grant")
70
+
71
+ def get(self, url):
72
+ Grants(network_sources=frozenset({"web"}) if self.origins else frozenset()).require_network("web")
73
+ _, origin = origin_of(url)
74
+ if origin not in self.origins:
75
+ raise ValueError("URL origin is outside the launch grant")
76
+
77
+ def transport(target, *, headers, timeout, max_bytes):
78
+ # No ambient proxies, credentials, cookies or redirect authority.
79
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
80
+ request = urllib.request.Request(target, headers=headers)
81
+ try:
82
+ response = opener.open(request, timeout=timeout)
83
+ except urllib.error.HTTPError as exc:
84
+ exc.close()
85
+ raise ValueError(f"HTTP request refused or failed: {exc.code}") from None
86
+ with response:
87
+ deadline = monotonic() + 10
88
+ body = bytearray()
89
+ while True:
90
+ if monotonic() >= deadline:
91
+ raise ValueError("response exceeded the read time budget")
92
+ chunk = response.read1(min(65536, max_bytes + 1 - len(body)))
93
+ if not chunk:
94
+ if response.length not in {None, 0}:
95
+ raise ValueError("incomplete response; no complete receipt issued")
96
+ break
97
+ body.extend(chunk)
98
+ if len(body) > max_bytes:
99
+ raise ValueError("response exceeds client byte limit; no complete receipt issued")
100
+ return RawResponse(response.status, response.geturl(), tuple(response.headers.items()), bytes(body))
101
+
102
+ receipt, body = fetch(url, transport=transport, max_bytes=1_000_000, timeout=10, retries=0)
103
+ return json.dumps({"text": decode_body(body or b""), "receipt": receipt.as_dict(),
104
+ "authority": "explicit-launch-origin", "content_trust": "untrusted-source"})
105
+
106
+
107
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
108
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
109
+ return None