@namewta/speculo 2.0.6 → 2.1.1

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 (273) hide show
  1. package/README.md +3 -0
  2. package/THIRD-PARTY-NOTICES.md +4 -0
  3. package/dist/src/agent-skills.d.ts +5 -0
  4. package/dist/src/agent-skills.js +10 -7
  5. package/dist/src/agent-skills.js.map +1 -1
  6. package/dist/src/cli.js +25 -36
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/coordination.d.ts +3 -3
  9. package/dist/src/coordination.js +21 -4
  10. package/dist/src/coordination.js.map +1 -1
  11. package/dist/src/doctor.js +8 -1
  12. package/dist/src/doctor.js.map +1 -1
  13. package/dist/src/external-files.d.ts +4 -2
  14. package/dist/src/external-files.js +4 -4
  15. package/dist/src/external-files.js.map +1 -1
  16. package/dist/src/hub-catalog.d.ts +11 -0
  17. package/dist/src/hub-catalog.js +91 -0
  18. package/dist/src/hub-catalog.js.map +1 -0
  19. package/dist/src/hub-resources.json +1 -0
  20. package/dist/src/hub-skills.d.ts +53 -0
  21. package/dist/src/hub-skills.js +157 -0
  22. package/dist/src/hub-skills.js.map +1 -0
  23. package/dist/src/index.d.ts +6 -0
  24. package/dist/src/index.js +48 -47
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/install-ui.d.ts +16 -7
  27. package/dist/src/install-ui.js +143 -92
  28. package/dist/src/install-ui.js.map +1 -1
  29. package/dist/src/manifest.d.ts +2 -0
  30. package/dist/src/manifest.js +2 -0
  31. package/dist/src/manifest.js.map +1 -1
  32. package/dist/src/refresh.d.ts +1 -0
  33. package/dist/src/refresh.js +13 -10
  34. package/dist/src/refresh.js.map +1 -1
  35. package/dist/src/retained-workflows.d.ts +10 -0
  36. package/dist/src/retained-workflows.js +25 -0
  37. package/dist/src/retained-workflows.js.map +1 -0
  38. package/dist/src/runtime-entry.d.ts +1 -0
  39. package/dist/src/runtime-entry.js +1 -0
  40. package/dist/src/runtime-entry.js.map +1 -1
  41. package/dist/src/skill-trees.d.ts +27 -0
  42. package/dist/src/skill-trees.js +118 -0
  43. package/dist/src/skill-trees.js.map +1 -0
  44. package/dist/src/terminal-ui.d.ts +30 -0
  45. package/dist/src/terminal-ui.js +148 -0
  46. package/dist/src/terminal-ui.js.map +1 -0
  47. package/dist/src/transaction.d.ts +3 -14
  48. package/dist/src/transaction.js +20 -36
  49. package/dist/src/transaction.js.map +1 -1
  50. package/dist/src/workflow-install.d.ts +15 -0
  51. package/dist/src/workflow-install.js +104 -0
  52. package/dist/src/workflow-install.js.map +1 -0
  53. package/dist/src/workflows.js +1 -1
  54. package/dist/src/workflows.js.map +1 -1
  55. package/package.json +6 -2
  56. package/skills-hub/automation/grok-bot-team-steward/CHEATSHEET.md +17 -0
  57. package/skills-hub/automation/grok-bot-team-steward/INSTALL.md +23 -0
  58. package/skills-hub/automation/grok-bot-team-steward/LICENSE +21 -0
  59. package/skills-hub/automation/grok-bot-team-steward/README.md +9 -0
  60. package/skills-hub/automation/grok-bot-team-steward/SKILL.md +52 -0
  61. package/skills-hub/automation/grok-bot-team-steward/agents/openai.yaml +4 -0
  62. package/skills-hub/automation/grok-bot-team-steward/references/00-hard-rules.md +50 -0
  63. package/skills-hub/automation/grok-bot-team-steward/references/01-snapshot-schema.md +84 -0
  64. package/skills-hub/automation/grok-bot-team-steward/references/02-export-protocol.md +57 -0
  65. package/skills-hub/automation/grok-bot-team-steward/references/03-restore-protocol.md +64 -0
  66. package/skills-hub/automation/grok-bot-team-steward/references/04-inventory.md +37 -0
  67. package/skills-hub/automation/grok-bot-team-steward/references/05-conversations.md +71 -0
  68. package/skills-hub/automation/grok-bot-team-steward/references/06-sanitizer.md +39 -0
  69. package/skills-hub/automation/grok-bot-team-steward/references/07-prompts.md +87 -0
  70. package/skills-hub/automation/grok-bot-team-steward/references/08-verification.md +30 -0
  71. package/skills-hub/automation/grok-bot-team-steward/references/09-sources.md +31 -0
  72. package/skills-hub/automation/grok-bot-team-steward/scripts/scrub.sh +17 -0
  73. package/skills-hub/automation/grok-bot-team-steward/scripts/snapshot-name.sh +18 -0
  74. package/skills-hub/automation/grok-bot-team-steward/scripts/validate-snapshot.sh +62 -0
  75. package/skills-hub/automation/grok-bot-team-steward/steward/ACTIVATE.md +58 -0
  76. package/skills-hub/automation/grok-bot-team-steward/steward/SETUP-INSTRUCTIONS.md +33 -0
  77. package/skills-hub/automation/grok-bot-team-steward/steward/STEWARD-PROFILE.md +28 -0
  78. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/CHANGELOG.md +3 -0
  79. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/CHARTER.md +27 -0
  80. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/DROP-LIST.md +10 -0
  81. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/GAPS.md +6 -0
  82. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/INIT.md +14 -0
  83. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/MANIFEST.md +30 -0
  84. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/MANIFEST.yaml +20 -0
  85. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/ORG.md +15 -0
  86. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/README.md +8 -0
  87. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/RESTORE.md +13 -0
  88. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/ROSTER.md +11 -0
  89. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/GAPS.md +7 -0
  90. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/MEMORY.md +9 -0
  91. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/PROFILE.md +27 -0
  92. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/agent-spec.md +9 -0
  93. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/conversations/INDEX.md +5 -0
  94. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/conversations/main.md +9 -0
  95. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/routines.md +5 -0
  96. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/skills.md +5 -0
  97. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/connectors.md +7 -0
  98. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/COMPLETED.md +6 -0
  99. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/CREATED.md +6 -0
  100. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/RESTORE-NOTES.md +13 -0
  101. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/SANITIZER.md +7 -0
  102. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/SOURCE.md +8 -0
  103. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/rooms/_ROOM.md +8 -0
  104. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/routines/_ROUTINE.md +12 -0
  105. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/shared/shared-memory.md +5 -0
  106. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/shared/workspace-map.md +5 -0
  107. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/skills/_SKILL/SKILL.md +18 -0
  108. package/skills-hub/automation/herdr/SKILL.md +246 -0
  109. package/skills-hub/automation/orca/SKILL.md +328 -0
  110. package/skills-hub/coding/gitea-repo/LICENSE +21 -0
  111. package/skills-hub/coding/gitea-repo/SKILL.md +50 -0
  112. package/skills-hub/coding/gitea-repo/agents/openai.yaml +4 -0
  113. package/skills-hub/coding/gitea-repo/references/auth-and-scopes.md +58 -0
  114. package/skills-hub/coding/gitea-repo/references/endpoint-map.md +80 -0
  115. package/skills-hub/coding/gitea-repo/references/pagination-and-errors.md +34 -0
  116. package/skills-hub/coding/gitea-repo/scripts/gitea_api.py +814 -0
  117. package/skills-hub/coding/github-repo-steward/LICENSE +21 -0
  118. package/skills-hub/coding/github-repo-steward/SKILL.md +48 -0
  119. package/skills-hub/coding/github-repo-steward/agents/openai.yaml +4 -0
  120. package/skills-hub/coding/github-repo-steward/references/00-auth-and-tokens.md +73 -0
  121. package/skills-hub/coding/github-repo-steward/references/01-inventory.md +55 -0
  122. package/skills-hub/coding/github-repo-steward/references/02-lifecycle.md +57 -0
  123. package/skills-hub/coding/github-repo-steward/references/03-stars.md +42 -0
  124. package/skills-hub/coding/github-repo-steward/references/04-hygiene.md +40 -0
  125. package/skills-hub/coding/github-repo-steward/references/05-sources.md +42 -0
  126. package/skills-hub/coding/github-repo-steward/scripts/inventory.sh +64 -0
  127. package/skills-hub/coding/vscode-fullstack/LICENSE +21 -0
  128. package/skills-hub/coding/vscode-fullstack/SKILL.md +47 -0
  129. package/skills-hub/coding/vscode-fullstack/agents/openai.yaml +4 -0
  130. package/skills-hub/coding/vscode-fullstack/references/extensions-and-settings.md +181 -0
  131. package/skills-hub/coding/vscode-fullstack/references/profiles-and-validation.md +62 -0
  132. package/skills-hub/coding/vscode-fullstack/references/runtime-reuse.md +52 -0
  133. package/skills-hub/design/photo-retouch/LICENSE +21 -0
  134. package/skills-hub/design/photo-retouch/SKILL.md +50 -0
  135. package/skills-hub/design/photo-retouch/agents/openai.yaml +4 -0
  136. package/skills-hub/design/photo-retouch/references/basic-retouch.md +58 -0
  137. package/skills-hub/design/photo-retouch/references/formal-portrait.md +57 -0
  138. package/skills-hub/design/photo-retouch/references/object-and-canvas.md +82 -0
  139. package/skills-hub/design/photo-retouch/references/product-photo.md +41 -0
  140. package/skills-hub/design/photo-retouch/references/repair-and-look.md +61 -0
  141. package/skills-hub/design/photo-retouch/references/social-effects.md +55 -0
  142. package/skills-hub/design/photo-retouch/references/travel-creative.md +79 -0
  143. package/skills-hub/system/clash-client-profile/LICENSE +21 -0
  144. package/skills-hub/system/clash-client-profile/SKILL.md +79 -0
  145. package/skills-hub/system/clash-client-profile/agents/openai.yaml +4 -0
  146. package/skills-hub/system/clash-client-profile/references/acceptance.md +81 -0
  147. package/skills-hub/system/clash-client-profile/references/browser-privacy.md +35 -0
  148. package/skills-hub/system/clash-client-profile/references/clash-verge.md +40 -0
  149. package/skills-hub/system/clash-client-profile/references/dns-workflow.md +147 -0
  150. package/skills-hub/system/clash-client-profile/references/flclash.md +24 -0
  151. package/skills-hub/system/clash-client-profile/references/network-performance.md +25 -0
  152. package/skills-hub/system/clash-client-profile/references/platform-dns.md +109 -0
  153. package/skills-hub/system/clash-client-profile/references/sources.md +46 -0
  154. package/skills-hub/system/clash-client-profile/references/troubleshooting.md +82 -0
  155. package/skills-hub/system/clash-client-profile/scripts/dns_guard.py +650 -0
  156. package/skills-hub/system/clash-client-profile/scripts/proxy_doctor.py +213 -0
  157. package/skills-hub/system/clash-client-profile/scripts/render_rules.py +34 -0
  158. package/skills-hub/system/clash-client-profile/templates/ai-rules.yaml +18 -0
  159. package/skills-hub/system/clash-client-profile/templates/dns-request.example.json +9 -0
  160. package/skills-hub/system/clash-client-profile/templates/dns.yaml +8 -0
  161. package/skills-hub/system/linux-dev-disk-cleanup/LICENSE +21 -0
  162. package/skills-hub/system/linux-dev-disk-cleanup/SKILL.md +42 -0
  163. package/skills-hub/system/linux-dev-disk-cleanup/agents/openai.yaml +4 -0
  164. package/skills-hub/system/linux-dev-disk-cleanup/references/linux-cleanup-workflow.md +145 -0
  165. package/skills-hub/system/optimize-codex-config/LICENSE +21 -0
  166. package/skills-hub/system/optimize-codex-config/SKILL.md +101 -0
  167. package/skills-hub/system/optimize-codex-config/agents/openai.yaml +4 -0
  168. package/skills-hub/system/optimize-codex-config/assets/capabilities.example.json +22 -0
  169. package/skills-hub/system/optimize-codex-config/references/cli.md +35 -0
  170. package/skills-hub/system/optimize-codex-config/references/configuration-contract.md +33 -0
  171. package/skills-hub/system/optimize-codex-config/references/optimization-method.md +38 -0
  172. package/skills-hub/system/optimize-codex-config/references/privacy.md +41 -0
  173. package/skills-hub/system/optimize-codex-config/references/sources.md +24 -0
  174. package/skills-hub/system/optimize-codex-config/references/statusline.md +31 -0
  175. package/skills-hub/system/optimize-codex-config/references/transactions.md +32 -0
  176. package/skills-hub/system/optimize-codex-config/references/troubleshooting.md +95 -0
  177. package/skills-hub/system/optimize-codex-config/scripts/adapters/claude.mjs +24 -0
  178. package/skills-hub/system/optimize-codex-config/scripts/adapters/codex.mjs +10 -0
  179. package/skills-hub/system/optimize-codex-config/scripts/ai-cli-config.mjs +67 -0
  180. package/skills-hub/system/optimize-codex-config/scripts/audit-codex-config.mjs +762 -0
  181. package/skills-hub/system/optimize-codex-config/scripts/lib/document.mjs +166 -0
  182. package/skills-hub/system/optimize-codex-config/scripts/lib/inspect.mjs +211 -0
  183. package/skills-hub/system/optimize-codex-config/scripts/lib/io.mjs +104 -0
  184. package/skills-hub/system/optimize-codex-config/scripts/lib/plan.mjs +79 -0
  185. package/skills-hub/system/optimize-codex-config/scripts/lib/transaction.mjs +205 -0
  186. package/skills-hub/system/optimize-codex-config/scripts/lib/writers.mjs +46 -0
  187. package/skills-hub/system/optimize-codex-config/scripts/statusline/claude-statusline.mjs +94 -0
  188. package/skills-hub/system/optimize-codex-config/scripts/vendor/THIRD-PARTY-NOTICES.txt +250 -0
  189. package/skills-hub/system/optimize-codex-config/scripts/vendor/build-package-lock.json +488 -0
  190. package/skills-hub/system/optimize-codex-config/scripts/vendor/manifest.json +14 -0
  191. package/skills-hub/system/optimize-codex-config/scripts/vendor/parsers.mjs +16 -0
  192. package/skills-hub/system/proxy-region-locale/LICENSE +21 -0
  193. package/skills-hub/system/proxy-region-locale/SKILL.md +42 -0
  194. package/skills-hub/system/proxy-region-locale/agents/openai.yaml +4 -0
  195. package/skills-hub/system/proxy-region-locale/references/linux.md +35 -0
  196. package/skills-hub/system/proxy-region-locale/references/macos.md +31 -0
  197. package/skills-hub/system/proxy-region-locale/references/windows.md +45 -0
  198. package/skills-hub/system/system-health-audit/LICENSE +21 -0
  199. package/skills-hub/system/system-health-audit/SKILL.md +41 -0
  200. package/skills-hub/system/system-health-audit/agents/openai.yaml +4 -0
  201. package/skills-hub/system/system-health-audit/references/platforms.md +37 -0
  202. package/skills-hub/system/system-health-audit/scripts/system_audit.py +41 -0
  203. package/skills-hub/system/windows-dev-disk-cleanup/LICENSE +21 -0
  204. package/skills-hub/system/windows-dev-disk-cleanup/SKILL.md +40 -0
  205. package/skills-hub/system/windows-dev-disk-cleanup/agents/openai.yaml +4 -0
  206. package/skills-hub/system/windows-dev-disk-cleanup/references/windows-cleanup-workflow.md +121 -0
  207. package/skills-hub/writing/.gitkeep +0 -0
  208. package/skills-hub/writing/job-application/LICENSE +21 -0
  209. package/skills-hub/writing/job-application/SKILL.md +160 -0
  210. package/skills-hub/writing/job-application/agents/openai.yaml +4 -0
  211. package/skills-hub/writing/job-application/assets/resume.css +10 -0
  212. package/skills-hub/writing/job-application/config.example.json +9 -0
  213. package/skills-hub/writing/job-application/references/company-research.md +7 -0
  214. package/skills-hub/writing/job-application/references/corpus-schema.md +33 -0
  215. package/skills-hub/writing/job-application/references/honesty-gate.md +29 -0
  216. package/skills-hub/writing/job-application/references/improvement-plan.md +15 -0
  217. package/skills-hub/writing/job-application/references/ingest-protocol.md +18 -0
  218. package/skills-hub/writing/job-application/references/interview-speech.md +16 -0
  219. package/skills-hub/writing/job-application/references/jd-parse.md +18 -0
  220. package/skills-hub/writing/job-application/references/persist-paths.md +34 -0
  221. package/skills-hub/writing/job-application/references/question-bank.md +19 -0
  222. package/skills-hub/writing/job-application/references/rendering.md +9 -0
  223. package/skills-hub/writing/job-application/references/resume-playbook.md +30 -0
  224. package/skills-hub/writing/job-application/scripts/README.md +12 -0
  225. package/skills-hub/writing/job-application/scripts/ingest_kb.py +188 -0
  226. package/skills-hub/writing/job-application/scripts/render_resume.py +144 -0
  227. package/skills-hub/writing/job-application/scripts/validate_claims.py +52 -0
  228. package/skills-hub/writing/job-application/scripts/write_config.py +72 -0
  229. package/skills-hub/writing/job-application/templates/application-index.md +10 -0
  230. package/skills-hub/writing/job-application/templates/brief.md +16 -0
  231. package/skills-hub/writing/job-application/templates/changelog.md +6 -0
  232. package/skills-hub/writing/job-application/templates/company-research.md +15 -0
  233. package/skills-hub/writing/job-application/templates/corpus.md +11 -0
  234. package/skills-hub/writing/job-application/templates/evidence-map.md +5 -0
  235. package/skills-hub/writing/job-application/templates/framings.md +6 -0
  236. package/skills-hub/writing/job-application/templates/improvement-plan.md +16 -0
  237. package/skills-hub/writing/job-application/templates/ingest-log.md +4 -0
  238. package/skills-hub/writing/job-application/templates/known-gaps.md +4 -0
  239. package/skills-hub/writing/job-application/templates/match-report.md +21 -0
  240. package/skills-hub/writing/job-application/templates/methodology-cache.md +10 -0
  241. package/skills-hub/writing/job-application/templates/persist-index.md +11 -0
  242. package/skills-hub/writing/job-application/templates/profile.md +21 -0
  243. package/skills-hub/writing/job-application/templates/questions.md +22 -0
  244. package/skills-hub/writing/job-application/templates/resume-original.md +10 -0
  245. package/skills-hub/writing/job-application/templates/resume-tailored.md +21 -0
  246. package/skills-hub/writing/job-application/templates/sources-index.md +6 -0
  247. package/skills-hub/writing/job-application/templates/speech.md +19 -0
  248. package/skills-hub/writing/job-application/templates/stories.md +9 -0
  249. package/skills-hub/writing/job-application/templates/story-bank.md +11 -0
  250. package/skills-hub/writing/job-application/templates/strengths.md +4 -0
  251. package/skills-hub/writing/job-application/templates/style-memory.md +7 -0
  252. package/skills-hub/writing/ste-zh/LICENSE +21 -0
  253. package/skills-hub/writing/ste-zh/NOTICE.md +9 -0
  254. package/skills-hub/writing/ste-zh/SKILL.md +130 -0
  255. package/skills-hub/writing/ste-zh/agents/openai.yaml +6 -0
  256. package/skills-hub/writing/ste-zh/examples/zh-report.md +23 -0
  257. package/skills-hub/writing/ste-zh/references/english-modes.md +13 -0
  258. package/skills-hub/writing/ste-zh/references/terminology.md +13 -0
  259. package/skills-hub/writing/ste-zh/references/zh-rules.md +29 -0
  260. package/template/.speculo/refresh-contract.json +2 -0
  261. package/template/canonical/canonical-specdev-goal-plan.md +33 -25
  262. package/template/canonical/canonical-specdev-grill-with-docs.md +214 -206
  263. package/template/canonical/canonical-specdev-spec.md +214 -206
  264. package/template/canonical/canonical-specdev-tickets.md +214 -206
  265. package/template/canonical/canonical-specdev-wayfinder.md +214 -206
  266. package/template/lib/speculo-runtime.mjs +183 -8
  267. package/template/references/coordination/coordination-contract.md +11 -3
  268. package/template/references/coordination/modes/cli.md +2 -2
  269. package/template/references/coordination/modes/herdr.md +9 -9
  270. package/template/references/coordination/modes/orca.md +11 -11
  271. package/template/references/coordination/project-facts.schema.json +1 -0
  272. package/template/workflows/specdev/specdev-i-init-setup/SKILL.md +7 -6
  273. package/template/workflows/specdev/specdev-i-init-setup/references/first-setup.md +30 -1
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env python3
2
+ """Mihomo DNS plan/audit and opt-in DoH probes. Python 3.10+, curl for probes.
3
+
4
+ Never edits a profile, changes OS settings, resolves names locally, or retries
5
+ without the specified proxy. JSON needs no dependencies; YAML needs PyYAML.
6
+ Output is a review plan, NOT a complete profile or a no-leak certificate.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import copy
12
+ import hashlib
13
+ import ipaddress
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import re
18
+ import secrets
19
+ import shutil
20
+ import struct
21
+ import subprocess
22
+ import sys
23
+ from urllib.parse import quote, unquote, urlsplit
24
+
25
+ LIMIT = 1024 * 1024
26
+ RESERVED = {"DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE", "GLOBAL", "RULES"}
27
+ LEAF_TYPES = {"ss", "ssr", "vmess", "vless", "trojan", "socks5", "http", "wireguard",
28
+ "hysteria", "hysteria2", "tuic", "snell", "ssh", "anytls"}
29
+ GROUP_TYPES = {"select", "url-test", "fallback", "load-balance", "relay"}
30
+ DNS_LISTS = ("nameserver", "fallback", "direct-nameserver")
31
+ DNS_MAPS = ("nameserver-policy", "proxy-server-nameserver-policy")
32
+ DOMESTIC_MARKERS = (
33
+ "system", "dhcp://", "223.5.5.5", "223.6.6.6", "119.29.29.29", "180.76.76.76",
34
+ "114.114.114.114", "2400:3200::1", "doh.pub", "alidns.com", "dnspod.cn", "dot.pub",
35
+ )
36
+ PENDING = ["installed_core_validation", "effective_config_readback", "application_rule_chain",
37
+ "physical_interface_bootstrap_probe", "os_dns_interception_udp_tcp", "native_ipv6",
38
+ "browser_doh_webrtc", "cold_start_and_provider_refresh", "sleep_network_change",
39
+ "crash_isolation", "rollback"]
40
+
41
+
42
+ def unique_pairs(pairs):
43
+ obj = {}
44
+ for key, value in pairs:
45
+ if key in obj:
46
+ raise ValueError("Duplicate mapping key; values withheld")
47
+ obj[key] = value
48
+ return obj
49
+
50
+
51
+ def load_document(path: Path, with_digest: bool = False):
52
+ """Bounded, duplicate-rejecting input; YAML aliases/custom tags are not allowed."""
53
+ try:
54
+ with path.open("rb") as handle:
55
+ raw = handle.read(LIMIT + 1)
56
+ if len(raw) > LIMIT:
57
+ raise ValueError("Document exceeds 1 MiB")
58
+ text = raw.decode("utf-8-sig")
59
+ if path.suffix.lower() == ".json" or text.lstrip().startswith(("{", "[")):
60
+ data = json.loads(text, object_pairs_hook=unique_pairs,
61
+ parse_constant=lambda _: (_ for _ in ()).throw(ValueError("Non-finite JSON")))
62
+ else:
63
+ try:
64
+ import yaml
65
+ except ImportError:
66
+ raise ValueError("YAML requires PyYAML; use a trusted local JSON export or install PyYAML explicitly") from None
67
+ class Loader(yaml.SafeLoader):
68
+ def compose_node(self, parent, index):
69
+ if self.check_event(yaml.AliasEvent):
70
+ raise ValueError("YAML aliases are not accepted; use a flattened effective export")
71
+ return super().compose_node(parent, index)
72
+ def mapping(loader, node):
73
+ return unique_pairs((loader.construct_object(k), loader.construct_object(v)) for k, v in node.value)
74
+ Loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping)
75
+ data = yaml.load(text, Loader=Loader)
76
+ if not isinstance(data, dict):
77
+ raise ValueError("Expected a mapping, not a scalar/list")
78
+ return (data, hashlib.sha256(raw).hexdigest()) if with_digest else data
79
+ except ValueError:
80
+ raise
81
+ except Exception:
82
+ # Parser errors can include a source line containing a secret.
83
+ raise ValueError("Cannot read/parse document; raw input and parser error withheld") from None
84
+
85
+
86
+ def group_name(value: object) -> str:
87
+ if (not isinstance(value, str) or not value or value != value.strip() or len(value) > 128
88
+ or any(ord(c) < 32 or ord(c) == 127 or c in ",#&=%" for c in value)
89
+ or value.upper() in RESERVED):
90
+ raise ValueError("Choose an exact non-reserved group name without delimiters or controls")
91
+ return value
92
+
93
+
94
+ def hostname(value: object) -> str:
95
+ if not isinstance(value, str):
96
+ raise ValueError("Expected a DNS hostname")
97
+ value = value.lower().rstrip(".")
98
+ if len(value) > 253 or len(value.split(".")) < 2 or not all(
99
+ re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", p) for p in value.split(".")):
100
+ raise ValueError("Use an ASCII/punycode DNS hostname, not a URL or wildcard")
101
+ try:
102
+ ipaddress.ip_address(value)
103
+ except ValueError:
104
+ return value
105
+ raise ValueError("An IP is not a DNS hostname")
106
+
107
+
108
+ def resolver_url(value: object, allow_selector: bool = False) -> tuple[str, str | None]:
109
+ """Intentionally supports only public numeric DoH/DoT; no implicit bootstrap.
110
+
111
+ Do not convert a provider's hostname URL into an IP URL without confirming its
112
+ certificate supports that IP. Private/provider-only DNS needs manual review.
113
+ """
114
+ try:
115
+ if not isinstance(value, str) or any(c.isspace() or ord(c) < 32 for c in value):
116
+ raise ValueError
117
+ u = urlsplit(value)
118
+ if u.scheme not in ("https", "tls") or u.username is not None or u.password is not None or u.query:
119
+ raise ValueError
120
+ addr = ipaddress.ip_address(u.hostname or "")
121
+ if not addr.is_global or "%" in (u.hostname or ""):
122
+ raise ValueError
123
+ if u.port is not None and not 1 <= u.port <= 65535:
124
+ raise ValueError
125
+ if u.scheme == "https" and (not u.path or u.path == "/"):
126
+ raise ValueError
127
+ if u.scheme == "tls" and u.path:
128
+ raise ValueError
129
+ selector = None
130
+ if u.fragment:
131
+ if not allow_selector or re.search(r"%(?![0-9a-fA-F]{2})", u.fragment):
132
+ raise ValueError
133
+ selector = group_name(unquote(u.fragment, errors="strict"))
134
+ elif "#" in value:
135
+ raise ValueError
136
+ return value.split("#", 1)[0], selector
137
+ except (ValueError, TypeError, UnicodeError):
138
+ raise ValueError("Expected public numeric HTTPS DoH or tls://IP, no credentials/query/unsafe DNS options") from None
139
+
140
+
141
+ def plaintext_bootstrap_url(value: object) -> str:
142
+ """tcp://public-IPv4-or-IPv6 on port 53 only. Node-name bootstrap, never a business resolver."""
143
+ try:
144
+ if not isinstance(value, str) or any(c.isspace() or ord(c) < 32 for c in value):
145
+ raise ValueError
146
+ u = urlsplit(value)
147
+ if (u.scheme != "tcp" or u.username is not None or u.password is not None or u.path
148
+ or u.query or u.fragment or "#" in value):
149
+ raise ValueError
150
+ addr = ipaddress.ip_address(u.hostname or "")
151
+ if not addr.is_global or "%" in (u.hostname or "") or (u.port is not None and u.port != 53):
152
+ raise ValueError
153
+ if domestic_resolver(value):
154
+ raise ValueError
155
+ return value
156
+ except (ValueError, TypeError):
157
+ raise ValueError("Plaintext bootstrap must be tcp://public-IP with no selector, and not a domestic resolver") from None
158
+
159
+
160
+ def domestic_resolver(value: object) -> bool:
161
+ if not isinstance(value, str):
162
+ return False
163
+ low = value.lower()
164
+ return any(marker in low for marker in DOMESTIC_MARKERS)
165
+
166
+
167
+ def policy_request(data: dict) -> dict:
168
+ if set(data) - {"schema_version", "group", "resolvers", "bootstrap", "ipv6", "targets",
169
+ "replace_existing_dns_policies", "routing"}:
170
+ raise ValueError("Unknown policy key; no implicit configuration changes")
171
+ if type(data.get("schema_version")) is not int or data["schema_version"] != 1:
172
+ raise ValueError("Expected policy schema_version 1")
173
+ if type(data.get("replace_existing_dns_policies", False)) is not bool:
174
+ raise ValueError("Policy replacement approval must be a boolean")
175
+ group = group_name(data.get("group"))
176
+ values = data.get("resolvers")
177
+ if not isinstance(values, list) or not 1 <= len(values) <= 4:
178
+ raise ValueError("Supply 1-4 explicitly selected encrypted resolvers")
179
+ resolvers = []
180
+ for value in values:
181
+ url = resolver_url(value)[0]
182
+ if domestic_resolver(url):
183
+ raise ValueError("Domestic or system resolver is outside the foreign-public profile")
184
+ resolvers.append(url)
185
+ resolvers = list(dict.fromkeys(resolvers))
186
+ routing = data.get("routing", "rule")
187
+ if routing not in ("rule", "global"):
188
+ raise ValueError("routing must be rule or global")
189
+ bootstrap = data.get("bootstrap", {"mode": "deny"})
190
+ if not isinstance(bootstrap, dict) or set(bootstrap) - {"mode", "resolvers", "acknowledge_direct_bootstrap",
191
+ "acknowledge_plaintext_metadata"}:
192
+ raise ValueError("Invalid bootstrap policy")
193
+ mode = bootstrap.get("mode")
194
+ if mode == "deny":
195
+ if set(bootstrap) != {"mode"}:
196
+ raise ValueError("deny bootstrap must not include direct resolvers or approval")
197
+ bootstrap = {"mode": "deny", "resolvers": []}
198
+ elif mode == "encrypted-direct":
199
+ if set(bootstrap) - {"mode", "resolvers", "acknowledge_direct_bootstrap"}:
200
+ raise ValueError("encrypted-direct does not accept a plaintext acknowledgement")
201
+ vals = bootstrap.get("resolvers")
202
+ if (bootstrap.get("acknowledge_direct_bootstrap") is not True or not isinstance(vals, list)
203
+ or not 1 <= len(vals) <= 4):
204
+ raise ValueError("Direct bootstrap requires explicit approval and 1-4 numeric encrypted resolvers")
205
+ chosen = []
206
+ for value in vals:
207
+ url = resolver_url(value)[0]
208
+ if domestic_resolver(url):
209
+ raise ValueError("Domestic or system resolver is outside the foreign-public profile")
210
+ chosen.append(url)
211
+ bootstrap = {"mode": mode, "resolvers": list(dict.fromkeys(chosen))}
212
+ elif mode == "plaintext-direct":
213
+ if set(bootstrap) != {"mode", "resolvers", "acknowledge_direct_bootstrap", "acknowledge_plaintext_metadata"}:
214
+ raise ValueError("Plaintext bootstrap requires both explicit acknowledgements")
215
+ vals = bootstrap.get("resolvers")
216
+ if (bootstrap.get("acknowledge_direct_bootstrap") is not True
217
+ or bootstrap.get("acknowledge_plaintext_metadata") is not True or not isinstance(vals, list)
218
+ or not 1 <= len(vals) <= 4):
219
+ raise ValueError("Plaintext bootstrap requires both approvals and 1-4 tcp://public-IP resolvers")
220
+ bootstrap = {"mode": mode, "resolvers": list(dict.fromkeys(plaintext_bootstrap_url(v) for v in vals))}
221
+ else:
222
+ raise ValueError("Choose bootstrap deny, encrypted-direct, or explicitly approved plaintext-direct")
223
+ if data.get("ipv6") not in ("tunnel", "block"):
224
+ raise ValueError("Choose IPv6 tunnel or block; OS-level verification is still required")
225
+ targets = data.get("targets")
226
+ if not isinstance(targets, list) or not 1 <= len(targets) <= 64:
227
+ raise ValueError("Supply 1-64 protected domain suffixes")
228
+ return {"group": group, "resolvers": resolvers, "bootstrap": bootstrap, "routing": routing,
229
+ "ipv6": data["ipv6"], "targets": list(dict.fromkeys(hostname(t) for t in targets)),
230
+ "replace_existing_dns_policies": data.get("replace_existing_dns_policies", False)}
231
+
232
+
233
+ def finding(items: list, code: str, severity: str = "error", location: str = ""):
234
+ items.append({"code": code, "severity": severity, "location": location})
235
+
236
+
237
+ def check_graph(config: dict, request: dict) -> list:
238
+ """Review ALL reachable choices, not only a possibly stale selected leaf."""
239
+ issues, objects = [], {}
240
+ for field in ("proxies", "proxy-groups"):
241
+ values = config.get(field, [])
242
+ if not isinstance(values, list) or len(values) > 4096:
243
+ finding(issues, "INVALID_PROXY_GRAPH", location=field)
244
+ continue
245
+ for index, obj in enumerate(values):
246
+ loc = f"{field}[{index}]"
247
+ if not isinstance(obj, dict) or not isinstance(obj.get("name"), str):
248
+ finding(issues, "INVALID_PROXY_OBJECT", location=loc)
249
+ continue
250
+ if obj["name"] in objects or obj["name"].upper() in RESERVED:
251
+ finding(issues, "DUPLICATE_OR_RESERVED_PROXY", location=loc)
252
+ objects[obj["name"]] = (obj, loc, field)
253
+ target = objects.get(request["group"])
254
+ if not target or target[2] != "proxy-groups":
255
+ finding(issues, "GROUP_MISSING_OR_NOT_A_GROUP")
256
+ return issues
257
+ visiting, visited = set(), set()
258
+ def visit(name, depth=0):
259
+ if depth > 64 or name in visiting:
260
+ finding(issues, "PROXY_GROUP_CYCLE_OR_DEPTH")
261
+ return
262
+ if name.upper() in RESERVED:
263
+ finding(issues, "DIRECT_REJECT_OR_RESERVED_CHOICE")
264
+ return
265
+ if name in visited:
266
+ return
267
+ item = objects.get(name)
268
+ if not item:
269
+ finding(issues, "UNRESOLVED_PROXY_REFERENCE")
270
+ return
271
+ obj, loc, field = item
272
+ visiting.add(name)
273
+ if field == "proxy-groups":
274
+ if obj.get("type") not in GROUP_TYPES:
275
+ finding(issues, "UNKNOWN_GROUP_TYPE", location=loc)
276
+ if obj.get("use") or obj.get("include-all") or obj.get("include-all-proxies") or obj.get("include-all-providers"):
277
+ finding(issues, "DYNAMIC_GROUP_MEMBERS_REQUIRE_EXPANDED_EXPORT", location=loc)
278
+ members = obj.get("proxies")
279
+ if not isinstance(members, list) or not members or not all(isinstance(m, str) for m in members):
280
+ finding(issues, "EMPTY_OR_UNKNOWN_GROUP_MEMBERS", location=loc)
281
+ else:
282
+ for member in members:
283
+ visit(member, depth + 1)
284
+ else:
285
+ if obj.get("type") not in LEAF_TYPES:
286
+ finding(issues, "UNKNOWN_OR_DIRECT_PROXY_TYPE", location=loc)
287
+ if not isinstance(obj.get("server"), str) or not obj["server"]:
288
+ finding(issues, "NODE_ENDPOINT_UNVERIFIED", location=loc)
289
+ if obj.get("dialer-proxy"):
290
+ if isinstance(obj["dialer-proxy"], str):
291
+ visit(obj["dialer-proxy"], depth + 1)
292
+ else:
293
+ finding(issues, "INVALID_DIALER_REFERENCE", location=loc)
294
+ if request["ipv6"] == "block":
295
+ try:
296
+ if ipaddress.ip_address(obj.get("server", "")).version == 6:
297
+ finding(issues, "IPV6_NODE_CONFLICTS_WITH_BLOCK_PROFILE", location=loc)
298
+ except (ValueError, TypeError):
299
+ finding(issues, "NODE_ADDRESS_FAMILY_REQUIRES_BOOTSTRAP_TEST", "review", loc)
300
+ if obj.get("skip-cert-verify") is True:
301
+ finding(issues, "NODE_TLS_VERIFICATION_DISABLED", location=loc)
302
+ visiting.remove(name)
303
+ visited.add(name)
304
+ visit(request["group"])
305
+ if request["bootstrap"]["mode"] == "deny":
306
+ # A global bootstrap change can break OTHER groups and provider refresh too.
307
+ for obj, loc, field in objects.values():
308
+ if field != "proxies":
309
+ continue
310
+ try:
311
+ ipaddress.ip_address(obj.get("server", ""))
312
+ except (ValueError, TypeError):
313
+ finding(issues, "HOSTNAME_NODE_NEEDS_BOOTSTRAP_OR_VERIFIED_PIN", location=loc)
314
+ if config.get("proxy-providers"):
315
+ finding(issues, "PROVIDER_COLD_START_UNVERIFIED", location="proxy-providers")
316
+ else:
317
+ finding(issues, "DIRECT_BOOTSTRAP_EXCEPTION_NOT_ALL_DNS_PROXIED", "review")
318
+ if request["bootstrap"]["mode"] == "plaintext-direct":
319
+ finding(issues, "PLAINTEXT_NODE_LOOKUP_VISIBLE_ON_PATH", "review")
320
+ return issues
321
+
322
+
323
+ def check_config(config: dict, request: dict) -> list:
324
+ issues = check_graph(config, request)
325
+ routing = request.get("routing", "rule")
326
+ if routing == "rule" and config.get("mode") != "rule":
327
+ finding(issues, "NOT_RULE_MODE", location="mode")
328
+ elif routing == "global" and config.get("mode") != "global":
329
+ finding(issues, "NOT_GLOBAL_MODE", location="mode")
330
+ elif routing == "global":
331
+ finding(issues, "GLOBAL_MODE_DOES_NOT_EVALUATE_PREPEND_RULES", "review", "mode")
332
+ dns, tun = config.get("dns"), config.get("tun")
333
+ if not isinstance(dns, dict) or not isinstance(tun, dict):
334
+ finding(issues, "NEED_FULL_EFFECTIVE_DNS_AND_TUN_MAPS")
335
+ return issues
336
+ if dns.get("enable") is not True:
337
+ finding(issues, "DNS_DISABLED_USES_SYSTEM", location="dns.enable")
338
+ if tun.get("enable") is not True or tun.get("auto-route") is not True:
339
+ finding(issues, "TUN_OR_AUTO_ROUTE_DISABLED", location="tun")
340
+ hijack = tun.get("dns-hijack", [])
341
+ if not isinstance(hijack, list) or not {"any:53", "tcp://any:53"}.issubset(hijack):
342
+ finding(issues, "UDP_TCP_53_CAPTURE_NOT_CONFIGURED", location="tun.dns-hijack")
343
+ if dns.get("prefer-h3") is True:
344
+ finding(issues, "H3_TRANSPORT_REQUIRES_SEPARATE_UDP_TEST", "review", "dns.prefer-h3")
345
+ if dns.get("enhanced-mode") != "fake-ip":
346
+ finding(issues, "REAL_IP_MODE_REQUIRES_DOMAIN_VISIBILITY_TEST", "review", "dns.enhanced-mode")
347
+ if request["ipv6"] == "tunnel" and (config.get("ipv6") is not True or dns.get("ipv6") is not True):
348
+ finding(issues, "IPV6_TUNNEL_FLAGS_MISSING", location="ipv6")
349
+ if request["ipv6"] == "block" and (config.get("ipv6") is not False or dns.get("ipv6") is not False):
350
+ finding(issues, "IPV6_BLOCK_PROFILE_FLAGS_MISSING", location="ipv6")
351
+ finding(issues, "NATIVE_IPV6_AND_CRASH_ISOLATION_REQUIRE_DEVICE_TEST", "review")
352
+ for key in ("route-address", "route-exclude-address", "route-address-set", "route-exclude-address-set",
353
+ "include-interface", "exclude-interface", "include-uid", "exclude-uid", "include-uid-range", "exclude-uid-range"):
354
+ if tun.get(key):
355
+ finding(issues, "TUN_SCOPE_REQUIRES_REVIEW", "review", "tun." + key)
356
+ try:
357
+ fake = ipaddress.ip_network(dns.get("fake-ip-range", "198.18.0.1/16"), strict=False)
358
+ for excluded in tun.get("route-exclude-address", []):
359
+ net = ipaddress.ip_network(excluded, strict=False)
360
+ if net.version == fake.version and net.overlaps(fake):
361
+ finding(issues, "FAKE_IP_ROUTE_EXCLUDED", location="tun.route-exclude-address")
362
+ except (TypeError, ValueError):
363
+ finding(issues, "INVALID_FAKE_IP_OR_EXCLUDED_ROUTE")
364
+ for key in DNS_LISTS + ("default-nameserver", "proxy-server-nameserver"):
365
+ values = dns.get(key, [])
366
+ if not isinstance(values, list):
367
+ finding(issues, "INVALID_RESOLVER_LIST", location="dns." + key)
368
+ continue
369
+ if key in ("nameserver", "default-nameserver", "proxy-server-nameserver") and not values:
370
+ finding(issues, "REQUIRED_RESOLVER_LIST_EMPTY", location="dns." + key)
371
+ for idx, value in enumerate(values):
372
+ loc = f"dns.{key}[{idx}]"
373
+ if domestic_resolver(value):
374
+ finding(issues, "DOMESTIC_PUBLIC_RESOLVER", location=loc)
375
+ continue
376
+ mode = request["bootstrap"]["mode"]
377
+ if key == "proxy-server-nameserver" and mode == "deny" and value == "rcode://refused":
378
+ continue
379
+ if key == "proxy-server-nameserver" and mode == "deny":
380
+ finding(issues, "BOOTSTRAP_MUST_BE_DENIED", location=loc)
381
+ continue
382
+ if key == "proxy-server-nameserver" and mode == "plaintext-direct":
383
+ try:
384
+ if plaintext_bootstrap_url(value) not in request["bootstrap"]["resolvers"]:
385
+ finding(issues, "RESOLVER_NOT_APPROVED_OR_NOT_PINNED", location=loc)
386
+ except ValueError:
387
+ finding(issues, "UNSAFE_OR_UNSUPPORTED_DNS_TRANSPORT", location=loc)
388
+ continue
389
+ try:
390
+ base, selector = resolver_url(value, allow_selector=True)
391
+ is_bootstrap = key == "proxy-server-nameserver" and mode == "encrypted-direct"
392
+ allowed = request["bootstrap"]["resolvers"] if is_bootstrap else request["resolvers"]
393
+ expected = None if is_bootstrap or request.get("routing") == "global" else request["group"]
394
+ if base not in allowed or selector != expected:
395
+ finding(issues, "RESOLVER_NOT_APPROVED_OR_NOT_PINNED", location=loc)
396
+ except ValueError:
397
+ finding(issues, "UNSAFE_OR_UNSUPPORTED_DNS_TRANSPORT", location=loc)
398
+ fallback = dns.get("fallback", [])
399
+ filt = dns.get("fallback-filter")
400
+ if isinstance(filt, dict) and filt.get("geoip") is True and (not isinstance(fallback, list) or not fallback):
401
+ finding(issues, "GEOIP_FILTER_WITHOUT_FALLBACK", location="dns.fallback-filter.geoip")
402
+ for key in DNS_MAPS:
403
+ values = dns.get(key, {})
404
+ if not isinstance(values, dict):
405
+ finding(issues, "INVALID_DNS_POLICY_MAP", location="dns." + key)
406
+ continue
407
+ # Indices, never private domain names, appear in audit output.
408
+ for idx, val in enumerate(values.values()):
409
+ loc = f"dns.{key}[{idx}]"
410
+ if key == "proxy-server-nameserver-policy":
411
+ finding(issues, "BOOTSTRAP_POLICY_OVERRIDE_REQUIRES_REVIEW", location=loc)
412
+ continue
413
+ entries = val if isinstance(val, list) else [val]
414
+ if not entries:
415
+ finding(issues, "EMPTY_DNS_POLICY", location=loc)
416
+ for entry in entries:
417
+ try:
418
+ base, selector = resolver_url(entry, True)
419
+ if base not in request["resolvers"] or selector != request["group"]:
420
+ finding(issues, "DNS_POLICY_BYPASSES_APPROVED_GROUP", location=loc)
421
+ except ValueError:
422
+ finding(issues, "DNS_POLICY_BYPASSES_APPROVED_GROUP", location=loc)
423
+ return issues
424
+
425
+
426
+ def build_plan(config: dict, request: dict) -> dict:
427
+ routing = request.get("routing", "rule")
428
+ if routing == "global":
429
+ routed = list(request["resolvers"])
430
+ else:
431
+ routed = [url + "#" + quote(request["group"], safe="") for url in request["resolvers"]]
432
+ boot = request["bootstrap"]["mode"]
433
+ if boot == "deny":
434
+ proxy_ns, profile = ["rcode://refused"], "strict-no-bootstrap"
435
+ elif boot == "encrypted-direct":
436
+ proxy_ns, profile = request["bootstrap"]["resolvers"], "encrypted-bootstrap-exception"
437
+ else:
438
+ proxy_ns, profile = request["bootstrap"]["resolvers"], "plaintext-bootstrap-exception"
439
+ dns = {"enable": True, "respect-rules": True, "prefer-h3": False,
440
+ "enhanced-mode": "fake-ip", "ipv6": request["ipv6"] == "tunnel",
441
+ "default-nameserver": routed, "nameserver": routed, "direct-nameserver": routed,
442
+ "proxy-server-nameserver": proxy_ns,
443
+ "nameserver-policy": {}, "proxy-server-nameserver-policy": {}, "fallback": [],
444
+ "fallback-filter": {"geoip": False, "domain": []}}
445
+ tun = {"enable": True, "auto-route": True, "dns-hijack": ["any:53", "tcp://any:53"]}
446
+ if not isinstance(config.get("dns", {}), dict) or not isinstance(config.get("tun", {}), dict):
447
+ raise ValueError("dns/tun must be maps in the effective profile")
448
+ if not config.get("dns", {}).get("fake-ip-range"):
449
+ dns["fake-ip-range"] = "198.18.0.1/16"
450
+ old_hijack = config.get("tun", {}).get("dns-hijack", [])
451
+ if not isinstance(old_hijack, list) or not all(isinstance(x, str) for x in old_hijack):
452
+ raise ValueError("Invalid existing DNS hijack list")
453
+ tun["dns-hijack"] = list(dict.fromkeys(old_hijack + tun["dns-hijack"]))
454
+ candidate = {"mode": routing, "ipv6": request["ipv6"] == "tunnel", "dns": dns, "tun": tun}
455
+ effective = copy.deepcopy(config)
456
+ for key in ("dns", "tun"):
457
+ effective.setdefault(key, {}).update(candidate[key])
458
+ effective.update({"mode": routing, "ipv6": candidate["ipv6"]})
459
+ issues = check_config(effective, request)
460
+ finding(issues, "REPLACE_POLICY_MAPS_NOT_RECURSIVE_MERGE", "review")
461
+ if any(config.get("dns", {}).get(k) for k in DNS_MAPS):
462
+ finding(issues, "EXISTING_SPLIT_DNS_REMOVAL_NEEDS_APPROVAL",
463
+ "review" if request["replace_existing_dns_policies"] else "error")
464
+ finding(issues, "FAKE_IP_RANGE_MUST_NOT_OVERLAP_REAL_LAN_OR_VPN", "review")
465
+ blocked = any(i["severity"] == "error" for i in issues)
466
+ return {"schema_version": 1, "status": "BLOCKED" if blocked else "REVIEW_REQUIRED",
467
+ "privacy_profile": profile,
468
+ "runtime_verdict": "UNVERIFIED", "findings": issues,
469
+ "candidate_fragment": None if blocked else candidate,
470
+ "rules_extension": None if blocked else {"prepend": ["DOMAIN-SUFFIX," + t + "," + request["group"] for t in request["targets"]], "append": [], "delete": []},
471
+ "merge_contract": {"replace_maps": ["dns." + k for k in DNS_MAPS] + ["dns.fallback-filter"],
472
+ "replace_lists": ["dns." + k for k in DNS_LISTS + ("default-nameserver", "proxy-server-nameserver")],
473
+ "preserve": ["subscription", "nodes", "credentials", "ports", "dns.listen", "dns.fake-ip-filter", "tun.stack", "tun.mtu", "unrelated rules"],
474
+ "note": "An empty map in a recursive GUI merge may NOT clear old entries; re-audit the generated effective profile"},
475
+ "pending_device_checks": PENDING,
476
+ "limitations": ["Plan only; not a complete profile or service-region guarantee", "Do not set OS DNS to an unverified listener", "IPv6 flags do not block native OS IPv6", "System/browser changes require separate scope and rollback", "Do not overwrite the live generated profile"]}
477
+
478
+
479
+ def proxy_endpoint(value: str) -> str:
480
+ try:
481
+ u = urlsplit(value)
482
+ if (u.scheme not in ("http", "https", "socks5h") or u.username is not None or u.password is not None
483
+ or not ipaddress.ip_address(u.hostname or "").is_loopback or not u.port
484
+ or u.path or u.query or u.fragment or any(c.isspace() or ord(c) < 32 for c in value)):
485
+ raise ValueError
486
+ except (ValueError, TypeError):
487
+ raise ValueError("Use an existing numeric-loopback HTTP/HTTPS/socks5h proxy with explicit port; no credentials") from None
488
+ return value
489
+
490
+
491
+ def dns_query(name: str, qtype: int, txid: int) -> bytes:
492
+ name = hostname(name)
493
+ if qtype not in (1, 28):
494
+ raise ValueError("Only A and AAAA queries are supported")
495
+ question = b"".join(bytes([len(label)]) + label.encode("ascii") for label in name.split(".")) + b"\0"
496
+ return struct.pack("!6H", txid, 0x0100, 1, 0, 0, 0) + question + struct.pack("!2H", qtype, 1)
497
+
498
+
499
+ def dns_name(data: bytes, offset: int) -> tuple[str, int]:
500
+ labels, seen, end = [], set(), None
501
+ for _ in range(128):
502
+ if offset >= len(data) or offset in seen:
503
+ raise ValueError("Malformed DNS name")
504
+ seen.add(offset)
505
+ length = data[offset]
506
+ if length & 0xC0 == 0xC0:
507
+ if offset + 1 >= len(data):
508
+ raise ValueError("Truncated DNS pointer")
509
+ if end is None:
510
+ end = offset + 2
511
+ offset = ((length & 0x3F) << 8) | data[offset + 1]
512
+ continue
513
+ if length & 0xC0:
514
+ raise ValueError("Unsupported DNS label")
515
+ offset += 1
516
+ if not length:
517
+ name = ".".join(labels)
518
+ if len(name) > 253:
519
+ raise ValueError("DNS name too long")
520
+ return name.lower(), end if end is not None else offset
521
+ if offset + length > len(data):
522
+ raise ValueError("Truncated DNS label")
523
+ labels.append(data[offset:offset + length].decode("ascii"))
524
+ offset += length
525
+ raise ValueError("Excessive DNS pointers")
526
+
527
+
528
+ def dns_response(data: bytes, txid: int, name: str, qtype: int) -> dict:
529
+ if not 12 <= len(data) <= 65535:
530
+ raise ValueError("Invalid DNS response size")
531
+ rid, flags, qd, an, ns, ar = struct.unpack("!6H", data[:12])
532
+ if rid != txid or not flags & 0x8000 or flags & 0x7800 or flags & 0x0200 or qd != 1 or an + ns + ar > 512:
533
+ raise ValueError("Mismatched, truncated, or unsupported DNS response")
534
+ question, offset = dns_name(data, 12)
535
+ if question != hostname(name) or offset + 4 > len(data) or struct.unpack("!2H", data[offset:offset + 4]) != (qtype, 1):
536
+ raise ValueError("DNS question mismatch")
537
+ offset += 4
538
+ answers = {"A": 0, "AAAA": 0}
539
+ for index in range(an + ns + ar):
540
+ _, offset = dns_name(data, offset)
541
+ if offset + 10 > len(data):
542
+ raise ValueError("Truncated DNS record")
543
+ kind, cls, _, size = struct.unpack("!HHIH", data[offset:offset + 10])
544
+ offset += 10
545
+ if offset + size > len(data) or (kind == 1 and size != 4) or (kind == 28 and size != 16):
546
+ raise ValueError("Invalid DNS record size")
547
+ if index < an and cls == 1 and kind in (1, 28):
548
+ answers["A" if kind == 1 else "AAAA"] += 1
549
+ offset += size
550
+ if offset != len(data):
551
+ raise ValueError("Unexpected trailing DNS bytes")
552
+ rcode = flags & 15
553
+ return {"rcode": rcode, "result": "ANSWER" if rcode == 0 and answers["A" if qtype == 1 else "AAAA"] else
554
+ "NODATA" if rcode == 0 else "NXDOMAIN" if rcode == 3 else "DNS_ERROR", "answer_counts": answers}
555
+
556
+
557
+ def doh_probe(proxy: str, resolver: str, name: str, qtype: int) -> dict:
558
+ proxy_endpoint(proxy)
559
+ resolver, _ = resolver_url(resolver)
560
+ if not resolver.startswith("https://"):
561
+ raise ValueError("Wire probe supports DoH only; DoT requires a separate path test")
562
+ executable = shutil.which("curl.exe" if os.name == "nt" else "curl")
563
+ if not executable:
564
+ return {"status": "UNVERIFIED", "reason": "curl unavailable"}
565
+ txid = secrets.randbelow(65536)
566
+ payload = dns_query(name, qtype, txid)
567
+ argv = [executable, "-q", "--silent", "--show-error", "--fail", "--connect-timeout", "4",
568
+ "--max-time", "10", "--max-filesize", "65535", "--proto", "=https", "--proxy", proxy,
569
+ "--noproxy", "", "--header", "Content-Type: application/dns-message",
570
+ "--header", "Accept: application/dns-message", "--data-binary", "@-",
571
+ "--write-out", "\n%{http_code}\t%{content_type}\n", resolver]
572
+ env = {k: v for k, v in os.environ.items() if k.lower() not in {"http_proxy", "https_proxy", "all_proxy", "no_proxy", "sslkeylogfile"}}
573
+ try:
574
+ result = subprocess.run(argv, input=payload, capture_output=True, check=False, timeout=12, env=env)
575
+ if result.returncode:
576
+ return {"status": "UNVERIFIED", "curl_exit": result.returncode, "reason": "Request failed; no direct fallback; raw errors withheld"}
577
+ if len(result.stdout) > 66048:
578
+ raise ValueError("Oversize DoH output")
579
+ wire, meta, tail = result.stdout.rsplit(b"\n", 2)
580
+ code, content_type = meta.split(b"\t", 1)
581
+ if tail or code != b"200" or content_type.split(b";", 1)[0].strip().lower() != b"application/dns-message":
582
+ raise ValueError("Unexpected HTTP status or DNS MIME type")
583
+ answer = dns_response(wire, txid, name, qtype)
584
+ return {"status": "FAIL" if answer["result"] == "DNS_ERROR" else "OBSERVED", **answer,
585
+ "note": "Only the local proxy listener was used; its final DIRECT/proxy leaf, core DNS settings, OS capture and resolver geography remain unverified"}
586
+ except (OSError, subprocess.TimeoutExpired, ValueError, UnicodeError, struct.error):
587
+ return {"status": "UNVERIFIED", "reason": "Unavailable or invalid DoH response; no direct retry; raw data withheld"}
588
+
589
+
590
+ def emit(data: dict):
591
+ # UTF-8 even with a redirected Windows legacy code page.
592
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
593
+ if hasattr(sys.stdout, "buffer"):
594
+ sys.stdout.buffer.write(text.encode("utf-8"))
595
+ else:
596
+ sys.stdout.write(text)
597
+
598
+
599
+ def main(argv: list[str] | None = None) -> int:
600
+ parser = argparse.ArgumentParser(description=__doc__)
601
+ sub = parser.add_subparsers(dest="command", required=True)
602
+ for cmd in ("plan", "audit"):
603
+ p = sub.add_parser(cmd, help="Offline only; never edits or loads a profile")
604
+ p.add_argument("--config", type=Path, required=True, help="Private full effective YAML or JSON profile, not a partial controller /configs snapshot")
605
+ p.add_argument("--request", type=Path, required=True, help="Explicit policy request JSON")
606
+ probe_parser = sub.add_parser("probe", help="Explicit-proxy RFC8484 DoH path test; no OS interception claim")
607
+ probe_parser.add_argument("--network", action="store_true", help="Authorize remote resolver to see the query and proxy exit IP")
608
+ probe_parser.add_argument("--proxy", required=True)
609
+ probe_parser.add_argument("--resolver", required=True, help="Explicit public numeric https:// DoH URL; no automatic provider choice")
610
+ probe_parser.add_argument("--domain", required=True)
611
+ probe_parser.add_argument("--qtype", choices=("A", "AAAA", "both"), default="both")
612
+ args = parser.parse_args(argv)
613
+ try:
614
+ if args.command in ("plan", "audit"):
615
+ config, fingerprint = load_document(args.config, with_digest=True)
616
+ request = policy_request(load_document(args.request))
617
+ if args.command == "plan":
618
+ report = build_plan(config, request)
619
+ report["input_profile_sha256"] = fingerprint
620
+ code = 1 if report["status"] == "BLOCKED" else 0
621
+ else:
622
+ issues = check_config(config, request)
623
+ blocked = any(i["severity"] == "error" for i in issues)
624
+ report = {"schema_version": 1, "status": "BLOCKED" if blocked else "UNVERIFIED",
625
+ "findings": issues, "pending_device_checks": PENDING,
626
+ "note": "Static audit cannot certify DNS privacy, application access or fail-closed behavior"}
627
+ code = 1 if blocked else 3
628
+ else:
629
+ if not args.network:
630
+ parser.error("probe requires --network; remote resolver sees the query and exit IP")
631
+ proxy_endpoint(args.proxy)
632
+ base, _ = resolver_url(args.resolver)
633
+ name = hostname(args.domain)
634
+ if not base.startswith("https://"):
635
+ raise ValueError("probe requires an HTTPS DoH resolver")
636
+ types = (1, 28) if args.qtype == "both" else (1,) if args.qtype == "A" else (28,)
637
+ results = [{"qtype": "A" if t == 1 else "AAAA", **doh_probe(args.proxy, base, name, t)} for t in types]
638
+ report = {"schema_version": 1, "runtime_verdict": "UNVERIFIED", "network_requested": True,
639
+ "probes": results, "note": "Domain, addresses and raw DNS payloads withheld; AAAA is a record type, not the transport IP family"}
640
+ code = 1 if any(r["status"] == "FAIL" for r in results) else 3
641
+ emit(report)
642
+ return code
643
+ except (ValueError, TypeError, RecursionError):
644
+ # Do not echo user/config values (could include a subscription or secret).
645
+ emit({"schema_version": 1, "status": "INPUT_ERROR", "message": "Invalid/unsupported input; consult --help and dns-workflow.md. Raw values withheld."})
646
+ return 2
647
+
648
+
649
+ if __name__ == "__main__":
650
+ sys.exit(main())