@slicedearth/whoisleuth-cli 2.4.0 → 2.5.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 (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -1,1389 +1,21 @@
1
- import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
2
- import { LOOKUP_EVIDENCE_SCHEMA_VERSION, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../packages/contracts/lookup-evidence.mjs";
3
- import { RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA, } from "../packages/contracts/risk-calibration.mjs";
4
- import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, WORKSPACE_ARCHIVE_VERSION, } from "../packages/contracts/case-portability.mjs";
5
- import { CLI_COMMAND_SEMANTICS, CLI_HELP_GROUP_ORDER, } from "../packages/contracts/cli-command-semantics.mjs";
6
- import { CLI_FAIL_POLICIES_BY_COMMAND } from "./fail-policy.mjs";
7
- import { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES } from "../packages/investigation/investigation-manifest.mjs";
8
- const LEGACY_WORKSPACE_ARCHIVE_VERSIONS = SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS
9
- .filter((version) => version !== WORKSPACE_ARCHIVE_VERSION);
10
- const LEGACY_WORKSPACE_ARCHIVE_DESCRIPTION = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
11
- .map((version) => `version-${version}`)
12
- .join(' and ');
13
- const PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS = SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS
14
- .filter((version) => version > V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION && version !== LOOKUP_EVIDENCE_SCHEMA_VERSION)
15
- .join(', ');
16
- const LEGACY_WORKSPACE_ARCHIVE_SCOPE = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
17
- .map((version) => `v${version}`)
18
- .join(' and ');
19
- const INVESTIGATION_PLAN_RECIPES = Object.freeze([
20
- 'domain-triage',
21
- 'lookalike-review',
22
- 'owned-domain-review',
23
- 'historical-comparison',
24
- 'campaign-review',
25
- 'certificate-anomaly',
26
- 'registry-disagreement',
27
- 'evidence-handoff',
28
- 'planned-domain-change',
29
- 'post-change-verification',
30
- ]);
31
- const RUNNABLE_INVESTIGATION_PLAN_RECIPES = INVESTIGATION_PLAN_RECIPES;
32
- const CLI_CASE_OPERATIONS = ['show', 'open', 'note', 'pin', 'assess', 'recheck'];
33
- const CLI_META_ACTIONS = Object.freeze([
34
- Object.freeze({
35
- id: 'help',
36
- aliases: Object.freeze(['--help', '-h']),
37
- scope: 'root_or_command',
38
- precedence: 'before_command_grammar',
39
- bypassesOrdinaryRequirements: true,
40
- acceptsAdditionalArguments: false,
41
- }),
42
- Object.freeze({
43
- id: 'version',
44
- aliases: Object.freeze(['--version', '-V']),
45
- scope: 'root_only',
46
- precedence: 'before_command_grammar',
47
- bypassesOrdinaryRequirements: true,
48
- acceptsAdditionalArguments: false,
49
- }),
50
- ]);
51
- const CLI_META_ACTION_BY_ID = Object.freeze(Object.fromEntries(CLI_META_ACTIONS.map((action) => [action.id, action])));
52
- // Human-facing command reference kept separate from command execution so
53
- // additions do not expand the already broad runtime controller.
54
- const HELP_INTRO = `WHOISleuth CLI
55
- Domain investigation from your terminal.
56
-
57
- Quick start:
58
- whoisleuth
59
- whoisleuth example.test
60
- whoisleuth lookup example.test --deep
61
- cat domains.txt | whoisleuth bulk --jsonl
62
- `;
63
- const HELP_FOOTER = `
64
- Run "whoisleuth <command> --help" for focused usage and an example.
65
- With no arguments, an eligible interactive terminal opens a bounded launcher;
66
- redirected or unsupported terminals continue to print this help. No request
67
- starts until a Lookup plan is shown and the analyst confirms collection.
68
- Use --json or --jsonl where supported for machine-readable stdout.
69
- Use --output <file> for atomic private file output and --force to replace it.
70
- Use -- before positional filenames that begin with a hyphen; ./ also makes a
71
- filename unambiguous, for example: whoisleuth verify-artifact -- ./-evidence.json.
72
- Use --palette auto, light, or dark after the command to select a fixed terminal
73
- colour palette; --no-color, NO_COLOR, and redirected output still suppress ANSI.
74
- Use --config <file> and --profile <name> for explicit versioned safe defaults.
75
- Registry scaffold is the exception: its --profile selects a fixture capability
76
- profile and --config is rejected, so bootstrap defaults cannot alter fixtures.
77
- An ICANN-recognised public domain, reserved documentation domain, IP, or ASN
78
- may replace "lookup"; URL-like or ambiguous input requires the explicit
79
- command. Both forms use the same Lookup options.
80
- Diagnostics are written to stderr. Fast lookup is the default; deep collection
81
- must be requested explicitly and can disclose a target to additional sources.
82
-
83
- Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
84
- Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
85
- `;
86
- const COMMON_OPTIONS = Object.freeze([
87
- '--help', '--output', '--force', '--config', '--profile', '--palette',
88
- ]);
89
- const REGISTRY_SCAFFOLD_COMMON_OPTIONS = Object.freeze(COMMON_OPTIONS.filter((option) => option !== '--config' && option !== '--profile'));
90
- function commonOptionsSeedForCommand(command) {
91
- return command === 'registry-scaffold' ? REGISTRY_SCAFFOLD_COMMON_OPTIONS : COMMON_OPTIONS;
1
+ import { INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES } from "./investigation-recipes.mjs";
2
+ import { CLI_COMMAND_SEMANTICS, CLI_HELP_GROUP_ORDER } from "../packages/contracts/cli-command-semantics.mjs";
3
+ import { INLINE_COMMAND_FAMILIES, CLI_CASE_OPERATIONS, CLI_INDICATOR_OPERATIONS, CLI_META_ACTIONS, CLI_META_ACTION_BY_ID, HELP_INTRO, HELP_FOOTER, commonOptionsForSeed, CLI_OPTION_DEFINITIONS, PRESENTATION_OPTIONS, optionSpec, grammarConstraints, } from "./command-definition.mjs";
4
+ import { COMMAND_SEEDS } from "./command-seeds.mjs";
5
+ const COMMAND_ORDER = Object.freeze(Object.keys(CLI_COMMAND_SEMANTICS));
6
+ function commandOptionDefinition(command, option) {
7
+ return COMMAND_SEEDS[command].optionOverrides?.[option] ?? CLI_OPTION_DEFINITIONS[option];
92
8
  }
93
- function positional(name, valueKind, minimum, maximum, values = [], inputSource = 'argv', requiredWhenOptions = []) {
94
- return Object.freeze({
95
- name,
96
- valueKind,
97
- minimum,
98
- maximum,
99
- values: Object.freeze([...values]),
100
- inputSource,
101
- requiredWhenOptions: Object.freeze([...requiredWhenOptions]),
102
- });
103
- }
104
- const NO_POSITIONALS = Object.freeze([]);
105
- const OPTIONAL_FILE_POSITIONAL = Object.freeze([positional('source', 'file', 0, 1, [], 'argv_or_stdin')]);
106
- const OPTIONAL_TEXT_POSITIONAL = Object.freeze([positional('subject', 'text', 0, 1, [], 'argv_or_stdin')]);
107
- const NO_INTEGER_RANGES = () => Object.freeze([]);
108
- const BASE_INTEGER_RANGE = (minimum, maximum) => (Object.freeze({ minimum, maximum, whenOptionPresent: null }));
109
- const DEEP_INTEGER_RANGE = (minimum, maximum) => (Object.freeze({ minimum, maximum, whenOptionPresent: '--deep' }));
110
- function optionDefinition(valueKind, options = {}) {
111
- const configuredValues = options.values;
112
- return Object.freeze({
113
- valueKind: typeof valueKind === 'function' ? valueKind : () => valueKind,
114
- values: typeof configuredValues === 'function'
115
- ? configuredValues
116
- : () => Object.freeze([...(configuredValues ?? [])]),
117
- occurrence: options.occurrence ?? 'once',
118
- acceptsOptionLikeValue: options.acceptsOptionLikeValue ?? false,
119
- metaAction: options.metaAction ?? null,
120
- integerRanges: options.integerRanges ?? NO_INTEGER_RANGES,
121
- });
122
- }
123
- const flag = (occurrence = 'once') => optionDefinition('flag', { occurrence });
124
- const file = () => optionDefinition('file');
125
- const text = (acceptsOptionLikeValue = false) => optionDefinition('text', { acceptsOptionLikeValue });
126
- const enumeration = (values) => optionDefinition('enum', { values });
127
- const integer = (ranges) => (optionDefinition('integer', { integerRanges: ranges }));
128
- const CLI_OPTION_DEFINITIONS = Object.freeze({
129
- '--help': optionDefinition('flag', { metaAction: 'help' }),
130
- '--output': file(),
131
- '--force': flag(),
132
- '--config': file(),
133
- '--profile': text(true),
134
- '--palette': enumeration(['auto', 'light', 'dark']),
135
- '--network': flag(),
136
- '--json': flag(),
137
- '--package': flag(),
138
- '--folder': file(),
139
- '--quiet': flag('idempotent'),
140
- '--no-color': flag('idempotent'),
141
- '--common': flag(),
142
- '--group': enumeration(['investigate', 'respond', 'assure', 'utilities']),
143
- '--mode': enumeration(['offline', 'network']),
144
- '--workflow': text(true),
145
- '--configuration-digest': text(true),
146
- '--junit': flag(),
147
- '--markdown': flag(),
148
- '--html': flag(),
149
- '--no-attribution': flag(),
150
- '--fast': flag(),
151
- '--deep': flag(),
152
- '--exact-url': flag(),
153
- '--observer': text(),
154
- '--vantage': text(),
155
- '--plan': flag(),
156
- '--summary': flag(),
157
- '--verbose': flag(),
158
- '--browse': flag(),
159
- '--interactive': flag(),
160
- '--save-lookup': file(),
161
- '--strict-exit': flag(),
162
- '--fail-on': optionDefinition('policy_list', {
163
- values: (command) => CLI_FAIL_POLICIES_BY_COMMAND[command] ?? [],
164
- }),
165
- '--events': flag(),
166
- '--jsonl': flag(),
167
- '--csv': flag(),
168
- '--csv-with-metadata': flag(),
169
- '--domains': flag(),
170
- '--queries': flag(),
171
- '--registered-only': flag(),
172
- '--inconclusive-only': flag(),
173
- '--errors-only': flag(),
174
- '--concurrency': integer((command) => command === 'monitor-once'
175
- ? Object.freeze([BASE_INTEGER_RANGE(1, 3)])
176
- : Object.freeze([BASE_INTEGER_RANGE(1, 8), DEEP_INTEGER_RANGE(1, 3)])),
177
- '--checkpoint': file(),
178
- '--resume': optionDefinition((command) => command === 'workflow-run' ? 'file' : 'flag'),
179
- '--tlds': text(true),
180
- '--preset': enumeration(['common', 'impersonation', 'all']),
181
- '--families': text(),
182
- '--keyboard': enumeration(['qwerty', 'azerty', 'qwertz', 'all']),
183
- '--dictionary': file(),
184
- '--snapshot': file(),
185
- '--scan-limit': integer(() => Object.freeze([
186
- BASE_INTEGER_RANGE(1, 500),
187
- DEEP_INTEGER_RANGE(1, 50),
188
- ])),
189
- '--chunk-size': integer(() => Object.freeze([BASE_INTEGER_RANGE(1, 100)])),
190
- '--resolver': text(),
191
- '--allowlist': file(),
192
- '--observation-snapshot': file(),
193
- '--acquisition-only': flag(),
194
- '--suppressed-only': flag(),
195
- '--selectors': text(true),
196
- '--retired-selectors': text(true),
197
- '--mail-profile': enumeration(['standard', 'defensive-no-mail', 'parked']),
198
- '--sarif': flag(),
199
- '--owned-domain': flag(),
200
- '--include-inherited-dns': flag(),
201
- '--trust-anchor': file(),
202
- '--owned-or-authorized': flag(),
203
- '--active-probe': flag(),
204
- '--suffix': text(true),
205
- '--scenario': enumeration(['registered', 'not_found', 'inconclusive']),
206
- '--summary-json': flag(),
207
- '--passphrase-file': file(),
208
- '--manifest': file(),
209
- '--bagit': flag(),
210
- '--manifest-entry': enumeration(Array.from({ length: MAX_INVESTIGATION_MANIFEST_ARTIFACTS }, (_, index) => `artifact-${index + 1}`)),
211
- '--search': text(),
212
- '--require-match': flag(),
213
- '--reveal': flag(),
214
- '--expect-content-digest': text(true),
215
- '--private-key-file': file(),
216
- '--public-key-file': file(),
217
- '--trust-store-file': file(),
218
- '--mmdb': file(),
219
- '--audience': enumeration(['internal', 'trusted', 'public']),
220
- '--reviewed': flag(),
221
- '--previous': file(),
222
- '--limit': integer(() => Object.freeze([BASE_INTEGER_RANGE(1, 20)])),
223
- '--marking': enumeration(['clear', 'green', 'amber', 'amber-strict', 'red']),
224
- '--recipient-scope': enumeration(['public', 'community', 'organization', 'named-recipients']),
225
- '--purpose': text(),
226
- '--human-reviewed': flag('idempotent'),
227
- '--personal-data-reviewed': flag('idempotent'),
228
- '--redactions-confirmed': flag('idempotent'),
229
- '--list': flag(),
230
- '--explain': enumeration(INVESTIGATION_PLAN_RECIPES),
231
- '--select': optionDefinition('text', { occurrence: 'repeatable', acceptsOptionLikeValue: true }),
232
- '--use-artifact': optionDefinition('text', { occurrence: 'repeatable' }),
233
- '--confirm-review': optionDefinition('text', { occurrence: 'repeatable' }),
234
- '--approve-network': flag(),
235
- '--left-session': text(true),
236
- '--right-session': text(true),
237
- '--compact': flag(),
238
- '--case-id': text(),
239
- '--domain': text(),
240
- '--title': text(),
241
- '--new-incident': flag(),
242
- '--text': text(),
243
- '--note-file': file(),
244
- '--input': file(),
245
- '--expect-file-digest': text(),
246
- });
247
- function constraint(value) {
248
- if (value.kind === 'mutually_exclusive') {
249
- return Object.freeze({ ...value, options: Object.freeze([...value.options]) });
250
- }
251
- if (value.kind === 'required') {
252
- return Object.freeze({ ...value, options: Object.freeze([...value.options]) });
253
- }
254
- if (value.kind === 'excludes_all' || value.kind === 'value_excludes') {
255
- return Object.freeze({ ...value, excludedOptions: Object.freeze([...value.excludedOptions]) });
256
- }
257
- return Object.freeze({ ...value, requiredOptions: Object.freeze([...value.requiredOptions]) });
258
- }
259
- const EMPTY_CONSTRAINTS = Object.freeze([]);
260
- const FILE_OUTPUT_CONSTRAINTS = Object.freeze([
261
- constraint({ kind: 'requires_all', option: '--force', requiredOptions: ['--output'] }),
262
- ]);
263
- const QUIET_OUTPUT_CONSTRAINT = constraint({
264
- kind: 'mutually_exclusive',
265
- options: ['--quiet', '--output'],
266
- });
267
- const PRESENTATION_OPTIONS = Object.freeze([
268
- ['--json', 'JSON'], ['--jsonl', 'JSON Lines'], ['--junit', 'JUnit XML'],
269
- ['--csv', 'CSV'],
270
- ['--csv-with-metadata', 'CSV with evidence metadata'],
271
- ['--domains', 'domain list'], ['--queries', 'query list'],
272
- ['--markdown', 'Markdown'], ['--html', 'HTML'], ['--sarif', 'SARIF'],
273
- ['--summary-json', 'summary JSON'],
274
- ]);
275
- const MACHINE_OUTPUT_OPTIONS = Object.freeze(PRESENTATION_OPTIONS.map(([option]) => option));
276
- function optionSpec(command, option, scope) {
277
- const definition = CLI_OPTION_DEFINITIONS[option];
278
- const valueKind = definition.valueKind(command);
279
- const values = definition.values(command);
280
- return Object.freeze({
281
- option,
282
- scope,
283
- arity: valueKind === 'flag' ? 0 : 1,
284
- valueKind,
285
- values: Object.freeze([...values]),
286
- integerRanges: Object.freeze([...definition.integerRanges(command)]),
287
- occurrence: definition.occurrence,
288
- acceptsOptionLikeValue: definition.acceptsOptionLikeValue,
289
- metaAction: definition.metaAction,
290
- });
9
+ export function commandHasOwner(command, owner) {
10
+ return isCliCommand(command) && COMMAND_SEEDS[command].handlerOwner === owner;
291
11
  }
292
- function grammarConstraints(commandOptions, commandConstraints) {
293
- const machineOutputOptions = commandOptions.filter((option) => MACHINE_OUTPUT_OPTIONS.includes(option));
294
- return Object.freeze([
295
- ...FILE_OUTPUT_CONSTRAINTS,
296
- ...(commandOptions.includes('--quiet') ? [QUIET_OUTPUT_CONSTRAINT] : []),
297
- ...(commandOptions.includes('--quiet') && machineOutputOptions.length > 0
298
- ? [constraint({ kind: 'excludes_all', option: '--quiet', excludedOptions: machineOutputOptions })]
299
- : []),
300
- ...(commandOptions.includes('--events')
301
- ? [constraint({ kind: 'mutually_exclusive', options: ['--events', '--output'] })]
302
- : []),
303
- ...(commandOptions.includes('--browse')
304
- ? [constraint({ kind: 'mutually_exclusive', options: ['--browse', '--output'] })]
305
- : []),
306
- ...commandConstraints,
307
- ]);
12
+ function inlineCommandFamily(command) {
13
+ const owner = COMMAND_SEEDS[command].handlerOwner;
14
+ return INLINE_COMMAND_FAMILIES.find(family => family === owner) ?? null;
308
15
  }
309
- function commandSeed(seed) {
310
- return Object.freeze({
311
- ...seed,
312
- reference: Object.freeze({ ...seed.reference }),
313
- collection: Object.freeze({ ...seed.collection }),
314
- options: Object.freeze([...seed.options]),
315
- positionals: Object.freeze([...seed.positionals]),
316
- constraints: Object.freeze([...seed.constraints]),
317
- schemaIdentifiers: Object.freeze([...seed.schemaIdentifiers]),
318
- primaryArtefacts: Object.freeze([...seed.primaryArtefacts]),
319
- additionalOutputFormats: Object.freeze([...seed.additionalOutputFormats]),
320
- });
16
+ function inlineCommandsFor(family) {
17
+ return Object.freeze(COMMAND_ORDER.filter((command) => COMMAND_SEEDS[command].handlerOwner === family));
321
18
  }
322
- const COMMAND_SEEDS = Object.freeze({
323
- completion: commandSeed({
324
- reference: {
325
- description: 'Print a static shell-completion script for the installed CLI.',
326
- example: 'whoisleuth completion zsh > ~/.zfunc/_whoisleuth',
327
- boundary: 'Generation is offline and writes only the script to stdout. The command never modifies shell configuration.',
328
- },
329
- collection: { mode: 'offline', scope: 'Prints one static script and changes no shell configuration.' },
330
- summary: 'Print shell completion',
331
- options: [],
332
- positionals: Object.freeze([positional('shell', 'enum', 1, 1, ['bash', 'zsh', 'fish', 'powershell'])]),
333
- constraints: EMPTY_CONSTRAINTS,
334
- handlerOwner: 'inline',
335
- networkEffect: 'offline',
336
- common: false,
337
- schemaIdentifiers: Object.freeze([]),
338
- primaryArtefacts: Object.freeze([]),
339
- planSupport: false,
340
- additionalOutputFormats: Object.freeze([]),
341
- bootstrapProfile: 'allowed',
342
- }),
343
- doctor: commandSeed({
344
- reference: {
345
- description: 'Check the supported runtime and local terminal capabilities.',
346
- example: 'whoisleuth doctor --json',
347
- boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
348
- },
349
- collection: { mode: 'network', scope: 'Network access is opt-in with --network and is limited to fixed public DNS, HTTPS, and WHOIS diagnostics.' },
350
- summary: 'Check the local CLI runtime',
351
- options: ['--network', '--json', '--quiet', '--no-color'],
352
- positionals: NO_POSITIONALS,
353
- constraints: EMPTY_CONSTRAINTS,
354
- handlerOwner: 'inline',
355
- networkEffect: 'conditional_network',
356
- common: true,
357
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.doctor']),
358
- primaryArtefacts: Object.freeze([]),
359
- planSupport: false,
360
- additionalOutputFormats: Object.freeze([]),
361
- bootstrapProfile: 'allowed',
362
- }),
363
- commands: commandSeed({
364
- reference: {
365
- description: 'List the installed command contracts in terminal or versioned JSON form.',
366
- example: 'whoisleuth commands --json',
367
- boundary: 'Catalogue generation is offline. It reports declared command modes and limits without executing collection or inspecting local evidence.',
368
- },
369
- collection: { mode: 'offline', scope: 'Reads the embedded command catalogue and performs no collection.' },
370
- summary: 'List installed command contracts',
371
- options: ['--common', '--group', '--mode', '--json', '--quiet', '--no-color'],
372
- positionals: NO_POSITIONALS,
373
- constraints: Object.freeze([]),
374
- handlerOwner: 'inline',
375
- networkEffect: 'offline',
376
- common: true,
377
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.command-catalogue']),
378
- primaryArtefacts: Object.freeze([]),
379
- planSupport: false,
380
- additionalOutputFormats: Object.freeze([]),
381
- bootstrapProfile: 'allowed',
382
- }),
383
- manual: commandSeed({
384
- reference: {
385
- description: 'Print a generated roff manual page for local installation.',
386
- example: 'whoisleuth manual | man -l -',
387
- boundary: 'Generation is offline and derives from the same command catalogue as focused help.',
388
- },
389
- collection: { mode: 'offline', scope: 'Builds documentation from the embedded command catalogue.' },
390
- summary: 'Print the generated manual page',
391
- options: [],
392
- positionals: NO_POSITIONALS,
393
- constraints: EMPTY_CONSTRAINTS,
394
- handlerOwner: 'inline',
395
- networkEffect: 'offline',
396
- common: false,
397
- schemaIdentifiers: Object.freeze([]),
398
- primaryArtefacts: Object.freeze([]),
399
- planSupport: false,
400
- additionalOutputFormats: Object.freeze([]),
401
- bootstrapProfile: 'allowed',
402
- }),
403
- manifest: commandSeed({
404
- reference: {
405
- description: `Record an ordered, path-free manifest for up to ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} local files. Use --package --output evidence.zip for a ZIP, add --passphrase-file to encrypt the ordinary package, or use --folder ./evidence for a new unencrypted folder. Add --bagit to ZIP or folder output for BagIt 1.0 with SHA-512 checksums.`,
406
- example: 'whoisleuth manifest lookup.json comparison.json --workflow "domain review" --json',
407
- boundary: 'Ordinary output contains metadata only. ZIP and folder output include unchanged selected bytes and are private until reviewed for sharing. Folders must be new; existing destinations are never replaced and a failed write may leave explicit partial output. BagIt output is unencrypted; its checksums do not authenticate evidence. Filenames ending in .json are parsed as JSON; other files are opaque and never executed. Original paths are omitted. No network request is made.',
408
- },
409
- collection: { mode: 'offline', scope: `Reads 1 to ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} local files, at most ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES / 1024 / 1024} MiB each and ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES.toLocaleString('en-AU')} bytes combined; retains no source paths.` },
410
- summary: 'Build an evidence manifest offline',
411
- options: ['--workflow', '--configuration-digest', '--package', '--bagit', '--passphrase-file', '--folder', '--json', '--quiet', '--no-color'],
412
- positionals: Object.freeze([positional('artefacts', 'file', 1, MAX_INVESTIGATION_MANIFEST_ARTIFACTS)]),
413
- constraints: Object.freeze([
414
- constraint({ kind: 'required', options: ['--workflow'] }),
415
- constraint({ kind: 'requires_any', option: '--bagit', requiredOptions: ['--package', '--folder'] }),
416
- constraint({ kind: 'excludes_all', option: '--bagit', excludedOptions: ['--passphrase-file'] }),
417
- constraint({ kind: 'requires_all', option: '--package', requiredOptions: ['--output'] }),
418
- constraint({ kind: 'requires_all', option: '--passphrase-file', requiredOptions: ['--package'] }),
419
- constraint({ kind: 'mutually_exclusive', options: ['--package', '--json'] }),
420
- constraint({ kind: 'excludes_all', option: '--folder', excludedOptions: ['--package', '--output', '--force'] }),
421
- ]),
422
- handlerOwner: 'inline',
423
- networkEffect: 'offline',
424
- common: false,
425
- schemaIdentifiers: Object.freeze(['whoisleuth.investigation-manifest']),
426
- primaryArtefacts: Object.freeze([]),
427
- planSupport: false,
428
- additionalOutputFormats: Object.freeze([]),
429
- bootstrapProfile: 'allowed',
430
- }),
431
- "map-observations": commandSeed({
432
- reference: {
433
- description: 'Apply one bounded declarative field-mapping profile to local source observations.',
434
- example: 'whoisleuth map-observations mapping.json --json',
435
- boundary: 'Profiles select allowlisted dotted fields only. They execute no scripts, make no requests, and emit the browser-compatible external-findings contract.',
436
- },
437
- collection: { mode: 'offline', scope: 'Reads one mapping document capped at 4 MiB and executes no scripts or requests.' },
438
- summary: 'Apply a declarative observation map offline',
439
- options: ['--json', '--quiet', '--no-color'],
440
- positionals: OPTIONAL_FILE_POSITIONAL,
441
- constraints: EMPTY_CONSTRAINTS,
442
- handlerOwner: 'inline',
443
- networkEffect: 'offline',
444
- common: false,
445
- schemaIdentifiers: Object.freeze(['whoisleuth.external-observation-mapping']),
446
- primaryArtefacts: Object.freeze([]),
447
- planSupport: false,
448
- additionalOutputFormats: Object.freeze([]),
449
- bootstrapProfile: 'allowed',
450
- }),
451
- "oam-export": commandSeed({
452
- reference: {
453
- description: 'Project browser-compatible external findings into a bounded Open Asset Model bridge document.',
454
- example: 'whoisleuth oam-export external-findings.json --json',
455
- boundary: 'The projection is offline, preserves source completeness without inventing confidence, and covers only bounded FQDN, IP address, certificate, and related edge vocabulary.',
456
- },
457
- collection: { mode: 'offline', scope: 'Reads one browser-compatible external-findings document and projects bounded graph records locally.' },
458
- summary: 'Project external findings to Open Asset Model',
459
- options: ['--json', '--quiet', '--no-color'],
460
- positionals: OPTIONAL_FILE_POSITIONAL,
461
- constraints: EMPTY_CONSTRAINTS,
462
- handlerOwner: 'inline',
463
- networkEffect: 'offline',
464
- common: false,
465
- schemaIdentifiers: Object.freeze(['whoisleuth.open-asset-model-bridge']),
466
- primaryArtefacts: Object.freeze([]),
467
- planSupport: false,
468
- additionalOutputFormats: Object.freeze([]),
469
- bootstrapProfile: 'allowed',
470
- }),
471
- lookup: commandSeed({
472
- reference: {
473
- description: 'Collect registration evidence for one domain, IP, or ASN.',
474
- example: 'whoisleuth lookup example.test --deep --browse',
475
- boundary: 'Fast is the default. An ICANN-recognised public domain, reserved documentation domain, IP, or ASN may occupy command position as shorthand; it delegates to this same parser and URL-like input requires the explicit lookup command. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable. --deep --exact-url explicitly sends the input URL path and query to the website, without its fragment; ordinary URL input sends only the hostname. --plan remains offline and omits the selected URL. Retained paths and page-derived text require review before sharing. A full Deep homepage observation can derive fixed publication and delivery/cache summaries from the same response without retaining raw metadata values or making another request. --browse opens before collection, shows aggregate Fast progress or independently settled planned Deep sources, and then navigates allowlisted retained fields in the completed document. Press ? for help and / to search rendered panel text only. Closing during collection cancels without a partial document. --save-lookup writes the exact completed private JSON only after a normal browser close; it can contain normalised evidence omitted from panels and refuses an existing path.',
476
- },
477
- collection: { mode: 'network', scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.' },
478
- summary: 'Collect one domain, IP, or ASN',
479
- options: ['--json', '--junit', '--markdown', '--html', '--no-attribution', '--fast', '--deep', '--exact-url', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--browse', '--save-lookup', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
480
- positionals: Object.freeze([positional('target', 'text', 0, 1, [], 'argv_or_stdin', ['--browse'])]),
481
- constraints: Object.freeze([
482
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit', '--markdown', '--html'] }),
483
- constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
484
- constraint({ kind: 'mutually_exclusive', options: ['--summary', '--verbose'] }),
485
- constraint({ kind: 'requires_all', option: '--save-lookup', requiredOptions: ['--browse'] }),
486
- constraint({ kind: 'requires_all', option: '--exact-url', requiredOptions: ['--deep'] }),
487
- constraint({ kind: 'excludes_all', option: '--summary', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
488
- constraint({ kind: 'excludes_all', option: '--verbose', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
489
- constraint({ kind: 'excludes_all', option: '--browse', excludedOptions: ['--json', '--junit', '--markdown', '--html', '--summary', '--verbose', '--events', '--plan', '--quiet'] }),
490
- constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
491
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--junit', '--markdown', '--html', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--fail-on'] }),
492
- ]),
493
- handlerOwner: 'lookup',
494
- networkEffect: 'conditional_network',
495
- common: true,
496
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup', 'whoisleuth.cli.lookup-plan']),
497
- primaryArtefacts: Object.freeze(['Source-qualified Lookup', 'Lookup request plan']),
498
- planSupport: true,
499
- additionalOutputFormats: Object.freeze([]),
500
- bootstrapProfile: 'allowed',
501
- }),
502
- bulk: commandSeed({
503
- reference: {
504
- description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
505
- example: 'cat domains.txt | whoisleuth bulk --jsonl',
506
- boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns.',
507
- },
508
- collection: { mode: 'network', scope: 'Accepts at most 500 fast or 50 deep targets, with concurrency capped at 8 fast or 3 deep.' },
509
- summary: 'Run bounded multi-target collection',
510
- options: ['--json', '--jsonl', '--junit', '--csv', '--csv-with-metadata', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--plan', '--fail-on', '--quiet', '--no-color'],
511
- positionals: OPTIONAL_FILE_POSITIONAL,
512
- constraints: Object.freeze([
513
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--junit', '--csv', '--csv-with-metadata', '--domains', '--queries'] }),
514
- constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--errors-only'] }),
515
- constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
516
- constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
517
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--junit', '--csv', '--csv-with-metadata', '--domains', '--queries', '--events', '--checkpoint', '--resume', '--quiet', '--fail-on'] }),
518
- ]),
519
- handlerOwner: 'bulk',
520
- networkEffect: 'conditional_network',
521
- common: true,
522
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.bulk', 'whoisleuth.cli.bulk.item', 'whoisleuth.cli.bulk-checkpoint']),
523
- primaryArtefacts: Object.freeze(['Bulk result', 'Bulk checkpoint']),
524
- planSupport: true,
525
- additionalOutputFormats: Object.freeze([]),
526
- bootstrapProfile: 'allowed',
527
- }),
528
- "ct-search": commandSeed({
529
- reference: {
530
- description: 'Search certificate-transparency observations for one bounded keyword.',
531
- example: 'whoisleuth ct-search "example brand" --json',
532
- boundary: 'Certificate observations do not prove website activity, registration ownership, or malicious intent.',
533
- },
534
- collection: { mode: 'network', scope: 'Accepts one bounded search keyword and queries the fixed certificate-transparency source.' },
535
- summary: 'Search certificate observations',
536
- options: ['--json', '--quiet', '--no-color'],
537
- positionals: Object.freeze([positional('keyword', 'text', 0, 1, [], 'argv_or_stdin')]),
538
- constraints: EMPTY_CONSTRAINTS,
539
- handlerOwner: 'network',
540
- networkEffect: 'always_network',
541
- common: false,
542
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.ct-search']),
543
- primaryArtefacts: Object.freeze([]),
544
- planSupport: false,
545
- additionalOutputFormats: Object.freeze([]),
546
- bootstrapProfile: 'allowed',
547
- }),
548
- "ct-intake": commandSeed({
549
- reference: {
550
- description: 'Normalise source-qualified local certificate events into browser-compatible findings.',
551
- example: 'whoisleuth ct-intake certificate-events.json --json',
552
- boundary: 'The command is offline, caps output at 100 findings, and treats every event as a review lead rather than proof of serving or control.',
553
- },
554
- collection: { mode: 'offline', scope: 'Reads one source-qualified event batch capped at 4 MiB and makes no request.' },
555
- summary: 'Normalise certificate observations offline',
556
- options: ['--json', '--quiet', '--no-color'],
557
- positionals: OPTIONAL_FILE_POSITIONAL,
558
- constraints: EMPTY_CONSTRAINTS,
559
- handlerOwner: 'inline',
560
- networkEffect: 'offline',
561
- common: false,
562
- schemaIdentifiers: Object.freeze(['whoisleuth.ct-event-batch', 'whoisleuth.external-findings']),
563
- primaryArtefacts: Object.freeze([]),
564
- planSupport: false,
565
- additionalOutputFormats: Object.freeze([]),
566
- bootstrapProfile: 'allowed',
567
- }),
568
- discover: commandSeed({
569
- reference: {
570
- description: 'Generate bounded lookalike-domain candidates from a brand or registrable domain, including multi-part public suffixes.',
571
- example: 'whoisleuth discover example.test --preset common --jsonl',
572
- boundary: 'Generation and optional local snapshot comparison are offline. Candidates are leads only and are not resolved, registered, or classified as malicious.',
573
- },
574
- collection: { mode: 'offline', scope: 'Generates a bounded candidate set from local rules, dictionaries, and optional saved snapshots.' },
575
- summary: 'Generate lookalike candidates offline',
576
- options: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
577
- positionals: OPTIONAL_TEXT_POSITIONAL,
578
- constraints: Object.freeze([
579
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--domains'] }),
580
- constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
581
- constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
582
- ]),
583
- handlerOwner: 'discovery',
584
- networkEffect: 'offline',
585
- common: true,
586
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.discover', 'whoisleuth.cli.discover.item', 'whoisleuth.cli.discovery-snapshot']),
587
- primaryArtefacts: Object.freeze(['Candidate set', 'Discovery snapshot']),
588
- planSupport: false,
589
- additionalOutputFormats: Object.freeze([]),
590
- bootstrapProfile: 'allowed',
591
- }),
592
- "discover-scan": commandSeed({
593
- reference: {
594
- description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
595
- example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
596
- boundary: 'This command performs network collection. Fast compact lookup is the default; deep mode is capped at 50 candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns.',
597
- },
598
- collection: { mode: 'network', scope: 'Scans at most 500 fast or 50 deep candidates, with concurrency capped at 8 fast or 3 deep.' },
599
- summary: 'Collect a supervised candidate review queue',
600
- options: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--fast', '--deep', '--scan-limit', '--chunk-size', '--concurrency', '--resolver', '--allowlist', '--checkpoint', '--resume', '--observation-snapshot', '--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only', '--events', '--plan', '--fail-on', '--json', '--jsonl', '--csv', '--csv-with-metadata', '--domains', '--quiet', '--no-color'],
601
- positionals: OPTIONAL_TEXT_POSITIONAL,
602
- constraints: Object.freeze([
603
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--csv', '--csv-with-metadata', '--domains'] }),
604
- constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
605
- constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
606
- constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only'] }),
607
- constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
608
- constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
609
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--csv', '--csv-with-metadata', '--domains', '--events', '--checkpoint', '--resume', '--observation-snapshot', '--quiet', '--fail-on'] }),
610
- ]),
611
- handlerOwner: 'discovery_scan',
612
- networkEffect: 'conditional_network',
613
- common: true,
614
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.discovery-scan', 'whoisleuth.cli.discovery-scan.item', 'whoisleuth.cli.discovery-observation-snapshot']),
615
- primaryArtefacts: Object.freeze(['Reviewed candidate queue', 'Observation snapshot']),
616
- planSupport: true,
617
- additionalOutputFormats: Object.freeze([]),
618
- bootstrapProfile: 'allowed',
619
- }),
620
- posture: commandSeed({
621
- reference: {
622
- description: 'Review bounded DNS mail, delegation, and domain-control posture.',
623
- example: 'whoisleuth posture example.test --mail-profile standard --json',
624
- boundary: 'Missing or failed DNS observations remain inconclusive. --include-inherited-dns explicitly adds a bounded DMARC tree walk and direct parent-delegation sample; records retain their queried owner and source. No message is sent and receiver enforcement is not inferred.',
625
- },
626
- collection: { mode: 'network', scope: 'Accepts one domain and performs bounded RDAP, DNS, and conditional MTA-STS HTTPS requests. --include-inherited-dns separately adds ancestor DMARC questions and direct DNS/TCP to sampled parent servers.' },
627
- summary: 'Review DNS and mail posture',
628
- options: ['--selectors', '--retired-selectors', '--mail-profile', '--include-inherited-dns', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
629
- positionals: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
630
- constraints: Object.freeze([
631
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--sarif'] }),
632
- constraint({ kind: 'requires_all', option: '--sarif', requiredOptions: ['--owned-domain'] }),
633
- ]),
634
- handlerOwner: 'network',
635
- networkEffect: 'always_network',
636
- common: false,
637
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.posture']),
638
- primaryArtefacts: Object.freeze([]),
639
- planSupport: false,
640
- additionalOutputFormats: Object.freeze([]),
641
- bootstrapProfile: 'allowed',
642
- }),
643
- http: commandSeed({
644
- reference: {
645
- description: 'Inspect one homepage request, redirects, and bounded response metadata.',
646
- example: 'whoisleuth http example.test --json',
647
- boundary: 'Requests use the shared public-address and redirect guards. Fixed content-coding and cache-policy metadata describes only the selected response, excludes raw header values, and does not prove caching, transfer savings, performance, privacy, or safety. This is not a rendered browser or vulnerability scan.',
648
- },
649
- collection: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
650
- summary: 'Inspect one homepage request',
651
- options: ['--json', '--quiet', '--no-color'],
652
- positionals: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
653
- constraints: EMPTY_CONSTRAINTS,
654
- handlerOwner: 'network',
655
- networkEffect: 'always_network',
656
- common: false,
657
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.http']),
658
- primaryArtefacts: Object.freeze([]),
659
- planSupport: false,
660
- additionalOutputFormats: Object.freeze([]),
661
- bootstrapProfile: 'allowed',
662
- }),
663
- tls: commandSeed({
664
- reference: {
665
- description: 'Inspect one hostname certificate through a bounded TLS connection.',
666
- example: 'whoisleuth tls example.test --json',
667
- boundary: 'One observed connection is point-in-time evidence and does not establish every address, edge, or historical certificate.',
668
- },
669
- collection: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
670
- summary: 'Inspect one TLS connection',
671
- options: ['--json', '--quiet', '--no-color'],
672
- positionals: Object.freeze([positional('hostname', 'text', 0, 1, [], 'argv_or_stdin')]),
673
- constraints: EMPTY_CONSTRAINTS,
674
- handlerOwner: 'network',
675
- networkEffect: 'always_network',
676
- common: false,
677
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.tls']),
678
- primaryArtefacts: Object.freeze([]),
679
- planSupport: false,
680
- additionalOutputFormats: Object.freeze([]),
681
- bootstrapProfile: 'allowed',
682
- }),
683
- "dnssec-validate": commandSeed({
684
- reference: {
685
- description: 'Cryptographically validate one authorised DNSSEC chain from a supplied trust anchor through one selected public resolver.',
686
- example: 'whoisleuth dnssec-validate example.test --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --json',
687
- boundary: 'This isolated action is never invoked by Lookup, Bulk, monitoring, or recipes. It caps DNS queries, aliases, delegations, bytes, and duration; transport and validation failures remain separate, and secure is not a general safety verdict.',
688
- },
689
- collection: { mode: 'network', scope: 'Accepts one authorised domain, one public resolver IP, and one local trust-anchor file; DNS-over-TCP validation is capped at 32 queries and 15 seconds.' },
690
- summary: 'Validate an authorised DNSSEC chain',
691
- options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--json', '--quiet', '--no-color'],
692
- positionals: Object.freeze([positional('domain', 'text', 1, 1)]),
693
- constraints: Object.freeze([
694
- constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized'] }),
695
- ]),
696
- handlerOwner: 'network',
697
- networkEffect: 'always_network',
698
- common: false,
699
- schemaIdentifiers: Object.freeze(['whoisleuth.dnssec-chain-validation', 'whoisleuth.dnssec-trust-anchor']),
700
- primaryArtefacts: Object.freeze([]),
701
- planSupport: false,
702
- additionalOutputFormats: Object.freeze([]),
703
- bootstrapProfile: 'allowed',
704
- }),
705
- "mail-transport": commandSeed({
706
- reference: {
707
- description: 'Review selected authorised MX endpoints, DNSSEC-qualified TLSA evidence, SMTP capabilities, and optional STARTTLS certificates.',
708
- example: 'whoisleuth mail-transport selected-mx.json --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --active-probe --json',
709
- boundary: 'This isolated action probes at most three selected MX hosts sequentially, reports selection, public revalidation, connection, and address authentication separately, sends only EHLO and optional STARTTLS, never retries, and performs no authentication, relay, recipient, mailbox, catch-all, or message test. If a DANE-TA TLSA usage 2 association is published, active collection retains only the leaf certificate and leaves that comparison partial without certificate-path construction and trust-anchor path validation. SMTP relay PKIX-TA usage 0 and PKIX-EE usage 1 records remain unsupported and cannot complete SMTP DANE assurance; a separate usage 3 match remains eligible.',
710
- },
711
- collection: { mode: 'network', scope: 'Accepts at most three selected authorised MX hosts, uses one public resolver, and performs sequential bounded SMTP connections with no retries.' },
712
- summary: 'Review selected authorised SMTP transports',
713
- options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe', '--json', '--quiet', '--no-color'],
714
- positionals: OPTIONAL_FILE_POSITIONAL,
715
- constraints: Object.freeze([
716
- constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe'] }),
717
- ]),
718
- handlerOwner: 'network',
719
- networkEffect: 'always_network',
720
- common: false,
721
- schemaIdentifiers: Object.freeze(['whoisleuth.mail-transport.input', 'whoisleuth.cli.mail-transport-review']),
722
- primaryArtefacts: Object.freeze([]),
723
- planSupport: false,
724
- additionalOutputFormats: Object.freeze([]),
725
- bootstrapProfile: 'allowed',
726
- }),
727
- "registry-support": commandSeed({
728
- reference: {
729
- description: 'Explain the local registry capability profile for one domain or suffix.',
730
- example: 'whoisleuth registry-support example.test --json',
731
- boundary: 'This command is offline. Catalogue coverage does not test live reachability or decide registration or availability.',
732
- },
733
- collection: { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
734
- summary: 'Explain local registry coverage',
735
- options: ['--json', '--quiet', '--no-color'],
736
- positionals: Object.freeze([positional('domain-or-suffix', 'text', 0, 1, [], 'argv_or_stdin')]),
737
- constraints: EMPTY_CONSTRAINTS,
738
- handlerOwner: 'inline',
739
- networkEffect: 'offline',
740
- common: false,
741
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.registry-support', 'whoisleuth.registry-standards-coverage']),
742
- primaryArtefacts: Object.freeze([]),
743
- planSupport: false,
744
- additionalOutputFormats: Object.freeze([]),
745
- bootstrapProfile: 'allowed',
746
- }),
747
- "registry-doctor": commandSeed({
748
- reference: {
749
- description: 'Compare a saved Lookup registry result with the reviewed local capability profile.',
750
- example: 'whoisleuth registry-doctor lookup.json --json',
751
- boundary: 'The command is offline. It distinguishes expected access constraints from collection results and does not contact a live registry.',
752
- },
753
- collection: { mode: 'offline', scope: 'Reads one saved Lookup and the embedded registry capability catalogue.' },
754
- summary: 'Diagnose saved registry collection',
755
- options: ['--json', '--quiet', '--no-color'],
756
- positionals: OPTIONAL_FILE_POSITIONAL,
757
- constraints: EMPTY_CONSTRAINTS,
758
- handlerOwner: 'inline',
759
- networkEffect: 'offline',
760
- common: false,
761
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.registry-doctor']),
762
- primaryArtefacts: Object.freeze([]),
763
- planSupport: false,
764
- additionalOutputFormats: Object.freeze([]),
765
- bootstrapProfile: 'allowed',
766
- }),
767
- "registry-cohort": commandSeed({
768
- reference: {
769
- description: 'Build privacy-safe suffix and capability-profile timelines from saved observations or retained cohort reports.',
770
- example: 'whoisleuth registry-cohort saved-lookups.jsonl --json',
771
- boundary: 'This command is offline and omits domains, queries, and raw evidence. Input families cannot be mixed, and retained samples are never assumed independent.',
772
- },
773
- collection: { mode: 'offline', scope: 'Reads at most 500 saved Lookups or retained cohort reports from one unmixed family and emits bounded target-free timelines.' },
774
- summary: 'Build target-free registry quality timelines',
775
- options: ['--json', '--quiet', '--no-color'],
776
- positionals: OPTIONAL_FILE_POSITIONAL,
777
- constraints: EMPTY_CONSTRAINTS,
778
- handlerOwner: 'inline',
779
- networkEffect: 'offline',
780
- common: false,
781
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.registry-cohort']),
782
- primaryArtefacts: Object.freeze([]),
783
- planSupport: false,
784
- additionalOutputFormats: Object.freeze([]),
785
- bootstrapProfile: 'allowed',
786
- }),
787
- "registry-scaffold": commandSeed({
788
- reference: {
789
- description: 'Create a bounded synthetic WHOIS fixture scaffold for one existing capability profile.',
790
- example: 'whoisleuth registry-scaffold --profile example-profile --suffix test --scenario registered',
791
- boundary: 'The output is a sanitised template only. Its command-owned --profile selects fixture capability, --config is rejected, and contributors must not paste live responses or personal registration data into fixtures.',
792
- },
793
- collection: { mode: 'offline', scope: 'Reads the embedded registry capability catalogue and prints one synthetic fixture template.' },
794
- summary: 'Create a sanitised registry fixture scaffold',
795
- options: ['--profile', '--suffix', '--scenario'],
796
- positionals: NO_POSITIONALS,
797
- constraints: Object.freeze([
798
- constraint({ kind: 'required', options: ['--profile', '--suffix', '--scenario'] }),
799
- ]),
800
- handlerOwner: 'inline',
801
- networkEffect: 'offline',
802
- common: false,
803
- schemaIdentifiers: Object.freeze([]),
804
- primaryArtefacts: Object.freeze([]),
805
- planSupport: false,
806
- additionalOutputFormats: Object.freeze([]),
807
- bootstrapProfile: 'command_owned',
808
- }),
809
- "risk-calibrate": commandSeed({
810
- reference: {
811
- description: 'Replay reviewed labels against the current explainable Risk model.',
812
- example: 'whoisleuth risk-calibrate calibration.json --summary-json',
813
- boundary: 'Calibration is offline and diagnostic. The summary form omits record identifiers, domains, and evidence; neither form trains, tunes, or changes the scoring model automatically.',
814
- },
815
- collection: { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
816
- summary: 'Replay reviewed Risk labels offline',
817
- options: ['--json', '--summary-json', '--quiet', '--no-color'],
818
- positionals: OPTIONAL_FILE_POSITIONAL,
819
- constraints: Object.freeze([
820
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--summary-json'] }),
821
- ]),
822
- handlerOwner: 'inline',
823
- networkEffect: 'offline',
824
- common: false,
825
- schemaIdentifiers: Object.freeze([RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA]),
826
- primaryArtefacts: Object.freeze([]),
827
- planSupport: false,
828
- additionalOutputFormats: Object.freeze([]),
829
- bootstrapProfile: 'allowed',
830
- }),
831
- "lookalike-calibrate": commandSeed({
832
- reference: {
833
- description: 'Summarise reviewed candidate dispositions by mutation family without retaining domains.',
834
- example: 'whoisleuth lookalike-calibrate reviewed-candidates.json --json',
835
- boundary: 'Calibration is offline and diagnostic. It omits candidate identifiers, domains, notes, and evidence and never tunes generation or filtering automatically.',
836
- },
837
- collection: { mode: 'offline', scope: 'Reads at most 5,000 reviewed candidate labels from one dataset capped at 2 MiB.' },
838
- summary: 'Summarise reviewed lookalike yield offline',
839
- options: ['--json', '--quiet', '--no-color'],
840
- positionals: OPTIONAL_FILE_POSITIONAL,
841
- constraints: EMPTY_CONSTRAINTS,
842
- handlerOwner: 'inline',
843
- networkEffect: 'offline',
844
- common: false,
845
- schemaIdentifiers: Object.freeze(['whoisleuth.lookalike-calibration-input', 'whoisleuth.lookalike-calibration']),
846
- primaryArtefacts: Object.freeze([]),
847
- planSupport: false,
848
- additionalOutputFormats: Object.freeze([]),
849
- bootstrapProfile: 'allowed',
850
- }),
851
- "verify-artifact": commandSeed({
852
- reference: {
853
- description: 'Validate a supported archive, ordinary Case export, claim passport, packet, manifest, saved Lookup or Lookup-evidence export without printing evidence contents. Use --package for an evidence ZIP or encrypted package, with --passphrase-file to unlock it; use --folder ./evidence for an unencrypted evidence folder. Add --bagit with --package or --folder to verify BagIt 1.0.',
854
- example: 'whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit',
855
- boundary: 'Verification is offline and redacted. ZIP and folder entries are reported separately without importing them. Case exports are checked without repairing content; ordinary package review also counts original references with matching bytes. Ordinary folders allow only the declared layout; BagIt allows bounded nested payloads and checks SHA-256/SHA-512 manifests without interpreting payloads. Symbolic links are refused. BagIt fetch.txt is never fetched; missing files, mismatches and unsupported algorithms remain explicit. Package digests describe bytes, not filesystem metadata or authenticity. In scripts, use --strict-exit: incomplete verification returns 4. Default exit 0 means the report was produced, not that its checks passed.',
856
- },
857
- collection: { mode: 'offline', scope: 'Reads one selected bounded artefact, ZIP or explicit evidence folder and, when explicitly supplied, one manifest whose selected entry is compared by exact bytes and canonical identity.' },
858
- summary: 'Validate saved evidence offline',
859
- options: ['--passphrase-file', '--manifest', '--manifest-entry', '--package', '--bagit', '--folder', '--json', '--strict-exit', '--quiet', '--no-color'],
860
- positionals: Object.freeze([positional('source', 'file', 0, 1, [], 'argv_or_stdin', ['--package'])]),
861
- constraints: Object.freeze([
862
- constraint({ kind: 'requires_any', option: '--bagit', requiredOptions: ['--package', '--folder'] }),
863
- constraint({ kind: 'excludes_all', option: '--bagit', excludedOptions: ['--passphrase-file', '--manifest', '--manifest-entry'] }),
864
- constraint({ kind: 'requires_all', option: '--manifest', requiredOptions: ['--manifest-entry'] }),
865
- constraint({ kind: 'requires_all', option: '--manifest-entry', requiredOptions: ['--manifest'] }),
866
- constraint({ kind: 'excludes_all', option: '--package', excludedOptions: ['--manifest', '--manifest-entry'] }),
867
- constraint({ kind: 'excludes_all', option: '--folder', excludedOptions: ['--package', '--manifest', '--manifest-entry', '--passphrase-file'] }),
868
- ]),
869
- handlerOwner: 'inline',
870
- networkEffect: 'offline',
871
- common: true,
872
- schemaIdentifiers: Object.freeze(['whoisleuth.offline-artifact-verification']),
873
- primaryArtefacts: Object.freeze(['Offline verification report']),
874
- planSupport: false,
875
- additionalOutputFormats: Object.freeze([]),
876
- bootstrapProfile: 'allowed',
877
- }),
878
- "interchange-report": commandSeed({
879
- reference: {
880
- description: 'Report what one recognised portable artefact preserves, excludes, and supports across browser and CLI workflows.',
881
- example: 'whoisleuth interchange-report workspace.json --json',
882
- boundary: 'The report is offline and metadata-only. It does not echo targets, contacts, notes, passphrases, evidence values, or an unrecognised schema string.',
883
- },
884
- collection: { mode: 'offline', scope: 'Reads one selected bounded portable artefact and emits fixed compatibility metadata only.' },
885
- summary: 'Report portable artefact fidelity offline',
886
- options: ['--passphrase-file', '--json', '--quiet', '--no-color'],
887
- positionals: OPTIONAL_FILE_POSITIONAL,
888
- constraints: EMPTY_CONSTRAINTS,
889
- handlerOwner: 'inline',
890
- networkEffect: 'offline',
891
- common: false,
892
- schemaIdentifiers: Object.freeze(['whoisleuth.interchange-fidelity-report']),
893
- primaryArtefacts: Object.freeze([]),
894
- planSupport: false,
895
- additionalOutputFormats: Object.freeze([]),
896
- bootstrapProfile: 'allowed',
897
- }),
898
- "inspect-archive": commandSeed({
899
- reference: {
900
- description: `Summarise or search one current version-${WORKSPACE_ARCHIVE_VERSION} workspace archive, with exact ${LEGACY_WORKSPACE_ARCHIVE_DESCRIPTION} support and redacted output by default.`,
901
- example: 'whoisleuth inspect-archive workspace.json --search example.test --json',
902
- boundary: 'Exact values require --reveal. New content comparisons use the reported sorted-json-v2:sha256 identity with --expect-content-digest; bare sha256 hashes retain their legacy locale-sensitive meaning. Retired and future archives are rejected. The archive is read locally and is never uploaded.',
903
- },
904
- collection: { mode: 'offline', scope: `Reads one selected bounded workspace archive v${WORKSPACE_ARCHIVE_VERSION}, retains exact ${LEGACY_WORKSPACE_ARCHIVE_SCOPE} compatibility, and redacts output by default.` },
905
- summary: 'Inspect an archive locally',
906
- options: ['--passphrase-file', '--search', '--require-match', '--reveal', '--expect-content-digest', '--json', '--quiet', '--no-color'],
907
- positionals: OPTIONAL_FILE_POSITIONAL,
908
- constraints: Object.freeze([
909
- constraint({ kind: 'requires_all', option: '--reveal', requiredOptions: ['--search'] }),
910
- constraint({ kind: 'requires_all', option: '--require-match', requiredOptions: ['--search'] }),
911
- ]),
912
- handlerOwner: 'evidence',
913
- networkEffect: 'offline',
914
- common: false,
915
- schemaIdentifiers: Object.freeze(['whoisleuth.workspace-archive-inspection']),
916
- primaryArtefacts: Object.freeze([]),
917
- planSupport: false,
918
- additionalOutputFormats: Object.freeze([]),
919
- bootstrapProfile: 'allowed',
920
- }),
921
- "sign-artifact": commandSeed({
922
- reference: {
923
- description: 'Sign one reviewed response packet or supported manifest with a local private key.',
924
- example: 'whoisleuth sign-artifact packet.json --private-key-file analyst-private.pem',
925
- boundary: 'The command never creates, stores, or transmits keys. Key custody and signer identity remain the operator\'s responsibility.',
926
- },
927
- collection: { mode: 'offline', scope: 'Reads one selected artefact and one local private key without transmitting either.' },
928
- summary: 'Sign a reviewed artefact locally',
929
- options: ['--private-key-file'],
930
- positionals: OPTIONAL_FILE_POSITIONAL,
931
- constraints: Object.freeze([
932
- constraint({ kind: 'required', options: ['--private-key-file'] }),
933
- ]),
934
- handlerOwner: 'evidence',
935
- networkEffect: 'offline',
936
- common: false,
937
- schemaIdentifiers: Object.freeze(['whoisleuth.signed-evidence-package']),
938
- primaryArtefacts: Object.freeze([]),
939
- planSupport: false,
940
- additionalOutputFormats: Object.freeze([]),
941
- bootstrapProfile: 'allowed',
942
- }),
943
- "verify-signature": commandSeed({
944
- reference: {
945
- description: 'Verify one signed package and report embedded-artefact assurance separately. --trust-store-file also checks an explicit local fingerprint policy and emits a signer-trust report; unknown, retired, revoked or future-reviewed entries exit 4.',
946
- example: 'whoisleuth verify-signature packet.signed.json --trust-store-file trust.json --json',
947
- boundary: 'A valid signature proves package consistency for the embedded key, not identity, authority or evidence accuracy. With --trust-store-file, unknown, retired, revoked or future-reviewed entries exit 4 even if --public-key-file matches. Replacement fingerprints need their own trusted entry; no signing date overrides current revocation.',
948
- },
949
- collection: { mode: 'offline', scope: 'Reads one selected signed package, optional local public key and explicit fingerprint trust file. No automatic trust discovery or network requests.' },
950
- summary: 'Verify a signed evidence package',
951
- options: ['--public-key-file', '--trust-store-file', '--json', '--quiet', '--no-color'],
952
- positionals: OPTIONAL_FILE_POSITIONAL,
953
- constraints: EMPTY_CONSTRAINTS,
954
- handlerOwner: 'evidence',
955
- networkEffect: 'offline',
956
- common: false,
957
- schemaIdentifiers: Object.freeze(['whoisleuth.evidence-signature-verification', 'whoisleuth.evidence-signer-trust-store', 'whoisleuth.evidence-signer-trust-report']),
958
- primaryArtefacts: Object.freeze([]),
959
- planSupport: false,
960
- additionalOutputFormats: Object.freeze([]),
961
- bootstrapProfile: 'allowed',
962
- }),
963
- "source-report": commandSeed({
964
- reference: {
965
- description: 'Create a target-free reliability summary from a saved lookup.',
966
- example: 'whoisleuth source-report lookup.json --json',
967
- boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
968
- },
969
- collection: { mode: 'offline', scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
970
- summary: 'Build a target-free source report',
971
- options: ['--json', '--quiet', '--no-color'],
972
- positionals: OPTIONAL_FILE_POSITIONAL,
973
- constraints: EMPTY_CONSTRAINTS,
974
- handlerOwner: 'inline',
975
- networkEffect: 'offline',
976
- common: false,
977
- schemaIdentifiers: Object.freeze(['whoisleuth.source-reliability-report']),
978
- primaryArtefacts: Object.freeze([]),
979
- planSupport: false,
980
- additionalOutputFormats: Object.freeze([]),
981
- bootstrapProfile: 'allowed',
982
- }),
983
- compare: commandSeed({
984
- reference: {
985
- description: 'Compare separately attributed registry publications in a saved lookup.',
986
- example: 'whoisleuth compare lookup.json --json',
987
- boundary: 'Comparison is offline. Differences are review context and do not by themselves prove which publication is current.',
988
- },
989
- collection: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
990
- summary: 'Compare registry publications in one lookup',
991
- options: ['--json', '--quiet', '--no-color'],
992
- positionals: OPTIONAL_FILE_POSITIONAL,
993
- constraints: EMPTY_CONSTRAINTS,
994
- handlerOwner: 'inline',
995
- networkEffect: 'offline',
996
- common: false,
997
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.compare']),
998
- primaryArtefacts: Object.freeze([]),
999
- planSupport: false,
1000
- additionalOutputFormats: Object.freeze([]),
1001
- bootstrapProfile: 'allowed',
1002
- }),
1003
- "page-compare": commandSeed({
1004
- reference: {
1005
- description: 'Compare static page identity, favicon, technology, and TLS evidence in two saved deep lookups.',
1006
- example: 'whoisleuth page-compare official.json candidate.json --json',
1007
- boundary: 'Comparison is offline and component-based. It executes no page code and produces no aggregate similarity or maliciousness score.',
1008
- },
1009
- collection: { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
1010
- summary: 'Compare saved static page evidence',
1011
- options: ['--json', '--quiet', '--no-color'],
1012
- positionals: Object.freeze([positional('sources', 'file', 2, 2)]),
1013
- constraints: EMPTY_CONSTRAINTS,
1014
- handlerOwner: 'inline',
1015
- networkEffect: 'offline',
1016
- common: false,
1017
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.page-compare']),
1018
- primaryArtefacts: Object.freeze([]),
1019
- planSupport: false,
1020
- additionalOutputFormats: Object.freeze([]),
1021
- bootstrapProfile: 'allowed',
1022
- }),
1023
- "mail-review": commandSeed({
1024
- reference: {
1025
- description: 'Review passive MX, null MX, SPF, DMARC, and shared mail-provider evidence from saved Bulk results.',
1026
- example: 'whoisleuth mail-review candidates.json --json',
1027
- boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
1028
- },
1029
- collection: { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
1030
- summary: 'Review saved passive mail evidence',
1031
- options: ['--json', '--quiet', '--no-color'],
1032
- positionals: OPTIONAL_FILE_POSITIONAL,
1033
- constraints: EMPTY_CONSTRAINTS,
1034
- handlerOwner: 'inline',
1035
- networkEffect: 'offline',
1036
- common: false,
1037
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.mail-review']),
1038
- primaryArtefacts: Object.freeze([]),
1039
- planSupport: false,
1040
- additionalOutputFormats: Object.freeze([]),
1041
- bootstrapProfile: 'allowed',
1042
- }),
1043
- "mail-headers": commandSeed({
1044
- reference: {
1045
- description: 'Review identity, reported authentication, domain alignment, and Received routing from selected message headers.',
1046
- example: 'whoisleuth mail-headers message.eml --json',
1047
- boundary: 'Review is offline. It makes no DNS, SMTP, HTTP, registry, or provider request, and does not retain address local parts, display names, subjects, message bodies, attachments, or raw header values. Reported authentication is not independently validated.',
1048
- },
1049
- collection: { mode: 'offline', scope: 'Parses only the bounded header block from one selected message or standard input.' },
1050
- summary: 'Review message headers offline',
1051
- options: ['--json', '--quiet', '--no-color'],
1052
- positionals: OPTIONAL_FILE_POSITIONAL,
1053
- constraints: EMPTY_CONSTRAINTS,
1054
- handlerOwner: 'inline',
1055
- networkEffect: 'offline',
1056
- common: false,
1057
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.mail-header-review']),
1058
- primaryArtefacts: Object.freeze([]),
1059
- planSupport: false,
1060
- additionalOutputFormats: Object.freeze([]),
1061
- bootstrapProfile: 'allowed',
1062
- }),
1063
- "review-evidence": commandSeed({
1064
- reference: {
1065
- description: 'Review one versioned DNS, domain-change, routing, GeoIP, RDAP, or trust-store document offline.',
1066
- example: 'whoisleuth review-evidence domain-change.json --json --strict-exit',
1067
- boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
1068
- },
1069
- collection: { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
1070
- summary: 'Review supplied evidence offline',
1071
- options: ['--mmdb', '--json', '--strict-exit', '--quiet', '--no-color'],
1072
- positionals: OPTIONAL_FILE_POSITIONAL,
1073
- constraints: EMPTY_CONSTRAINTS,
1074
- handlerOwner: 'inline',
1075
- networkEffect: 'offline',
1076
- common: true,
1077
- schemaIdentifiers: Object.freeze([
1078
- 'whoisleuth.cli.offline-evidence-review',
1079
- 'whoisleuth.rdap-search-input',
1080
- 'whoisleuth.dnssec-evidence-input',
1081
- 'whoisleuth.tlsa-evidence-input',
1082
- 'whoisleuth.rpki-route-input',
1083
- 'whoisleuth.local-geoip-query',
1084
- 'whoisleuth.encrypted-dns-plan-input',
1085
- ]),
1086
- primaryArtefacts: Object.freeze([]),
1087
- planSupport: false,
1088
- additionalOutputFormats: Object.freeze([]),
1089
- bootstrapProfile: 'allowed',
1090
- }),
1091
- brief: commandSeed({
1092
- reference: {
1093
- description: 'Turn one saved Lookup into a compact decision brief with facts, unknowns, contradictions, and next actions.',
1094
- example: 'whoisleuth brief lookup.json --json',
1095
- boundary: 'The command is offline, excludes raw upstream payloads, and does not create an analyst assertion or claim that the saved observation is current.',
1096
- },
1097
- collection: { mode: 'offline', scope: 'Reads one bounded saved Lookup and emits a compact source-attributed decision brief.' },
1098
- summary: 'Build a decision brief from a saved lookup',
1099
- options: ['--json', '--quiet', '--no-color'],
1100
- positionals: OPTIONAL_FILE_POSITIONAL,
1101
- constraints: EMPTY_CONSTRAINTS,
1102
- handlerOwner: 'inline',
1103
- networkEffect: 'offline',
1104
- common: false,
1105
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-brief']),
1106
- primaryArtefacts: Object.freeze([]),
1107
- planSupport: false,
1108
- additionalOutputFormats: Object.freeze([]),
1109
- bootstrapProfile: 'allowed',
1110
- }),
1111
- case: commandSeed({
1112
- reference: {
1113
- description: 'Show or open a local Case, append a note or evidence pin, record an assessment, or retain an offline recheck. Use --input for pin, assessment and recheck JSON; --text or --note-file for a note. Mutations require --output and always write the complete current Case export.',
1114
- example: 'whoisleuth case open --domain example.test --output cases.json\n whoisleuth case show cases.json\n whoisleuth case note cases.json --text "Review the retained observation" --output cases.json --force',
1115
- boundary: 'No database, browser launch, request or external report is created. Select --case-id when a file contains multiple Cases. Existing files require --force; --expect-file-digest sha256:<digest> additionally checks the exact file reviewed earlier. Source and output leases reject concurrent changes. Interrupted .workflow.lock files require deliberate inspection. Recheck records supplied observations; it does not collect them. Not reproduced requires an existing saved question, a complete observation and comparable conditions. Working exports include private analyst content and file references, not attached file bytes.',
1116
- },
1117
- collection: { mode: 'offline', scope: `Reads exact Case schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}. Input is bounded to ${MAX_EDITABLE_CASE_INPUT_BYTES / 1024 / 1024} MiB including formatting; the complete canonical Case store must fit ${MAX_CASE_STORE_BYTES / 1024 / 1024} MiB without pruning. Writes current schema ${CASE_SCHEMA_VERSION}.` },
1118
- summary: 'Review and update ordinary local Case files',
1119
- options: ['--case-id', '--domain', '--title', '--new-incident', '--text', '--note-file', '--input', '--expect-file-digest', '--json', '--no-color'],
1120
- positionals: Object.freeze([positional('operation', 'enum', 1, 1, CLI_CASE_OPERATIONS), positional('source', 'file', 0, 1)]),
1121
- constraints: Object.freeze([constraint({ kind: 'mutually_exclusive', options: ['--text', '--note-file'] })]),
1122
- handlerOwner: 'inline', networkEffect: 'offline', common: true,
1123
- schemaIdentifiers: Object.freeze(['whoisleuth.case-export']),
1124
- primaryArtefacts: Object.freeze(['Case export']), planSupport: false,
1125
- additionalOutputFormats: Object.freeze([]), bootstrapProfile: 'allowed',
1126
- }),
1127
- "case-pack": commandSeed({
1128
- reference: {
1129
- description: `Package browser-created Case records from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} as a reviewed, audience-specific Case-pack v2 with current schema ${CASE_SCHEMA_VERSION}.`,
1130
- example: 'whoisleuth case-pack cases.json --audience trusted --reviewed --json',
1131
- boundary: 'The command is an offline handoff from the browser Case workflow: it creates a new package, never creates or mutates a durable Case, never mutates the source archive, and requires an explicit review acknowledgement.',
1132
- },
1133
- collection: { mode: 'offline', scope: `Reads one bounded Case export from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} and writes a separate audience-specific Case-pack v2.` },
1134
- summary: 'Build a reviewed case package',
1135
- options: ['--audience', '--reviewed', '--json', '--quiet', '--no-color'],
1136
- positionals: OPTIONAL_FILE_POSITIONAL,
1137
- constraints: Object.freeze([
1138
- constraint({ kind: 'required', options: ['--audience', '--reviewed'] }),
1139
- ]),
1140
- handlerOwner: 'inline',
1141
- networkEffect: 'offline',
1142
- common: true,
1143
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.case-pack', 'whoisleuth.case-report']),
1144
- primaryArtefacts: Object.freeze(['Reviewed Case-pack v2']),
1145
- planSupport: false,
1146
- additionalOutputFormats: Object.freeze([]),
1147
- bootstrapProfile: 'allowed',
1148
- }),
1149
- "domain-control": commandSeed({
1150
- reference: {
1151
- description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
1152
- example: 'whoisleuth domain-control domain-control-input.json --json',
1153
- boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete, recent source observations can establish drift or expected absence.',
1154
- },
1155
- collection: { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
1156
- summary: 'Build or review a domain control manifest',
1157
- options: ['--json', '--quiet', '--no-color'],
1158
- positionals: OPTIONAL_FILE_POSITIONAL,
1159
- constraints: EMPTY_CONSTRAINTS,
1160
- handlerOwner: 'inline',
1161
- networkEffect: 'offline',
1162
- common: false,
1163
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.domain-control-review-input', 'whoisleuth.cli.domain-control-review']),
1164
- primaryArtefacts: Object.freeze([]),
1165
- planSupport: false,
1166
- additionalOutputFormats: Object.freeze([]),
1167
- bootstrapProfile: 'allowed',
1168
- }),
1169
- "monitor-once": commandSeed({
1170
- reference: {
1171
- description: 'Collect one bounded owned-domain review and compare it with an optional prior checkpoint.',
1172
- example: 'whoisleuth monitor-once manifest.json --previous previous.json --json --output next.json',
1173
- boundary: 'This is an operator-scheduled one-shot collection, not a daemon. It caps targets and concurrency, retains normalised observations, and never changes domain configuration.',
1174
- },
1175
- collection: { mode: 'network', scope: 'Runs deep collection for at most 20 manifest domains with concurrency capped at 3.' },
1176
- summary: 'Run one bounded domain control review',
1177
- options: ['--previous', '--limit', '--concurrency', '--fail-on', '--json', '--junit', '--quiet', '--no-color'],
1178
- positionals: OPTIONAL_FILE_POSITIONAL,
1179
- constraints: Object.freeze([
1180
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit'] }),
1181
- ]),
1182
- handlerOwner: 'inline',
1183
- networkEffect: 'always_network',
1184
- common: false,
1185
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.domain-control-monitor', 'whoisleuth.domain-control-flight-recorder.input']),
1186
- primaryArtefacts: Object.freeze([]),
1187
- planSupport: false,
1188
- additionalOutputFormats: Object.freeze([]),
1189
- bootstrapProfile: 'allowed',
1190
- }),
1191
- assurance: commandSeed({
1192
- reference: {
1193
- description: 'Review a versioned domain change, recovery-dependency, or retirement plan.',
1194
- example: 'whoisleuth assurance domain-assurance.json --json',
1195
- boundary: 'The command is offline and treats every provider label, readiness state, and evidence reference as analyst-authored input. It changes no configuration.',
1196
- },
1197
- collection: { mode: 'offline', scope: 'Reads one versioned plan capped at 2 MiB and makes no request or configuration change.' },
1198
- summary: 'Review domain change, recovery, or retirement plans',
1199
- options: ['--json', '--quiet', '--no-color'],
1200
- positionals: OPTIONAL_FILE_POSITIONAL,
1201
- constraints: EMPTY_CONSTRAINTS,
1202
- handlerOwner: 'inline',
1203
- networkEffect: 'offline',
1204
- common: false,
1205
- schemaIdentifiers: Object.freeze(['whoisleuth.domain-assurance.input', 'whoisleuth.domain-assurance']),
1206
- primaryArtefacts: Object.freeze([]),
1207
- planSupport: false,
1208
- additionalOutputFormats: Object.freeze([]),
1209
- bootstrapProfile: 'allowed',
1210
- }),
1211
- "change-packet": commandSeed({
1212
- reference: {
1213
- description: 'Assemble pre-change, post-change, and planning evidence into one integrity-protected packet.',
1214
- example: 'whoisleuth change-packet change-review.json --json',
1215
- boundary: 'Assembly is offline. Readiness reflects only the supplied bounded evidence and does not authorise or perform a domain change.',
1216
- },
1217
- collection: { mode: 'offline', scope: 'Reads one versioned packet input capped at 6 MiB and makes no request or configuration change.' },
1218
- summary: 'Build a reviewed change packet offline',
1219
- options: ['--json', '--quiet', '--no-color'],
1220
- positionals: OPTIONAL_FILE_POSITIONAL,
1221
- constraints: EMPTY_CONSTRAINTS,
1222
- handlerOwner: 'inline',
1223
- networkEffect: 'offline',
1224
- common: false,
1225
- schemaIdentifiers: Object.freeze(['whoisleuth.domain-change-packet.input', 'whoisleuth.domain-change-packet']),
1226
- primaryArtefacts: Object.freeze([]),
1227
- planSupport: false,
1228
- additionalOutputFormats: Object.freeze([]),
1229
- bootstrapProfile: 'allowed',
1230
- }),
1231
- "sharing-review": commandSeed({
1232
- reference: {
1233
- description: 'Lint one reviewed artefact against local integrity, marking, recipient, personal-data, and redaction controls.',
1234
- example: 'whoisleuth sharing-review packet.json --marking amber --recipient-scope organization --purpose "Reviewed incident handoff" --human-reviewed --personal-data-reviewed --redactions-confirmed --json',
1235
- boundary: 'The command is offline and emits only bounded schema/version metadata, no content values, and no raw evidence. Its result is a review aid, not legal advice or recipient authorisation.',
1236
- },
1237
- collection: { mode: 'offline', scope: 'Reads one bounded artefact, emits only schema/version metadata and no content values, and performs no transmission.' },
1238
- summary: 'Lint an artefact before deliberate sharing',
1239
- options: ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
1240
- positionals: OPTIONAL_FILE_POSITIONAL,
1241
- constraints: Object.freeze([
1242
- constraint({ kind: 'required', options: ['--marking', '--recipient-scope', '--purpose'] }),
1243
- ]),
1244
- handlerOwner: 'inline',
1245
- networkEffect: 'offline',
1246
- common: false,
1247
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.sharing-review']),
1248
- primaryArtefacts: Object.freeze([]),
1249
- planSupport: false,
1250
- additionalOutputFormats: Object.freeze([]),
1251
- bootstrapProfile: 'allowed',
1252
- }),
1253
- "workflow-plan": commandSeed({
1254
- reference: {
1255
- description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
1256
- example: 'whoisleuth workflow-plan domain-triage example.test --json',
1257
- boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
1258
- },
1259
- collection: { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
1260
- summary: 'Plan a fixed investigation recipe',
1261
- options: ['--list', '--explain', '--json', '--quiet', '--no-color'],
1262
- positionals: Object.freeze([
1263
- positional('recipe', 'enum', 0, 1, INVESTIGATION_PLAN_RECIPES),
1264
- positional('subject', 'text', 0, 1),
1265
- ]),
1266
- constraints: Object.freeze([
1267
- constraint({ kind: 'mutually_exclusive', options: ['--list', '--explain'] }),
1268
- ]),
1269
- handlerOwner: 'inline',
1270
- networkEffect: 'offline',
1271
- common: true,
1272
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.investigation-plan', 'whoisleuth.cli.workflow-recipe-catalogue']),
1273
- primaryArtefacts: Object.freeze(['Plan-only workflow document']),
1274
- planSupport: true,
1275
- additionalOutputFormats: Object.freeze([]),
1276
- bootstrapProfile: 'allowed',
1277
- }),
1278
- "workflow-run": commandSeed({
1279
- reference: {
1280
- description: 'Execute approved steps from a fixed investigation recipe and emit a resumable checkpoint.',
1281
- example: 'whoisleuth workflow-run domain-triage example.test --approve-network --json --output run.json',
1282
- boundary: 'Only installed recipe commands can run. Network steps require explicit approval for each invocation. New runs connect compatible earlier outputs using the recipe defaults. Use --use-artifact <step-id>:<input-number>=<earlier-step-id> to override a connection; input numbers start at 1. Repeat --select for remaining placeholders in order, or supply every input for a step to replace its connections with files. Values stay literal and cannot start with a hyphen or invoke a shell. Optional --interactive prompts on terminal stderr for missing inputs; a blank answer pauses. It grants neither network approval nor human-review confirmation. A step declaring human review still requires --confirm-review <step-id> for that invocation. Checkpoints do not grant later approvals. Resumes preserve recorded connections. Content digests identify retained output, not authenticity or freshness. Partial collections pause for review and are not recollected on resume; failed validation or export steps remain retryable. Diagnostics go to stderr. File output holds exclusive adjacent locks and refuses concurrently changed state files.',
1283
- },
1284
- collection: { mode: 'network', scope: 'Runs only fixed-recipe steps; network collection requires --approve-network and unresolved analyst selections pause.' },
1285
- summary: 'Execute approved fixed-recipe steps',
1286
- options: ['--select', '--use-artifact', '--confirm-review', '--approve-network', '--resume', '--interactive', '--json', '--quiet', '--no-color'],
1287
- positionals: Object.freeze([
1288
- positional('recipe', 'enum', 1, 1, RUNNABLE_INVESTIGATION_PLAN_RECIPES),
1289
- positional('subject', 'text', 1, 1),
1290
- ]),
1291
- constraints: EMPTY_CONSTRAINTS,
1292
- handlerOwner: 'inline',
1293
- networkEffect: 'conditional_network',
1294
- common: false,
1295
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.investigation-run']),
1296
- primaryArtefacts: Object.freeze(['Resumable workflow state']),
1297
- planSupport: false,
1298
- additionalOutputFormats: Object.freeze([]),
1299
- bootstrapProfile: 'allowed',
1300
- }),
1301
- diff: commandSeed({
1302
- reference: {
1303
- description: 'Compare an earlier and later artefact from the same retained Lookup, Bulk-session, or domain-portfolio family.',
1304
- example: 'whoisleuth diff earlier.json later.json --json',
1305
- boundary: 'Comparison is offline: the left input is earlier and the right input is later. Inputs must belong to the same supported family. Saved Lookups can describe the same or different domains; same-domain comparisons preserve observation times and collection uncertainty. For a multi-session Bulk export, --left-session selects a session from the left file and --right-session selects one from the right; missing, unavailable, equal, and different evidence remain separate states.',
1306
- },
1307
- collection: { mode: 'offline', scope: 'Reads two compatible retained artefacts capped at 8 MiB each and retains no source paths.' },
1308
- summary: 'Compare two compatible retained artefacts',
1309
- options: ['--left-session', '--right-session', '--json', '--quiet', '--no-color'],
1310
- positionals: Object.freeze([positional('sources', 'file', 2, 2)]),
1311
- constraints: EMPTY_CONSTRAINTS,
1312
- handlerOwner: 'inline',
1313
- networkEffect: 'offline',
1314
- common: true,
1315
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-diff']),
1316
- primaryArtefacts: Object.freeze(['Retained-evidence comparison']),
1317
- planSupport: false,
1318
- additionalOutputFormats: Object.freeze([]),
1319
- bootstrapProfile: 'allowed',
1320
- }),
1321
- reconcile: commandSeed({
1322
- reference: {
1323
- description: 'Reconcile bounded values across independently labelled observations of one domain.',
1324
- example: 'whoisleuth reconcile office.json mobile.json external.json --json',
1325
- boundary: 'The command is offline, accepts 2 to 5 saved observations for one domain, and never treats labels as proof of network independence or majority agreement as truth.',
1326
- },
1327
- collection: { mode: 'offline', scope: 'Reads 2 to 5 saved observations for one domain, capped at 32 MiB in total.' },
1328
- summary: 'Reconcile independently labelled observations',
1329
- options: ['--json', '--quiet', '--no-color'],
1330
- positionals: Object.freeze([positional('sources', 'file', 2, 5)]),
1331
- constraints: EMPTY_CONSTRAINTS,
1332
- handlerOwner: 'inline',
1333
- networkEffect: 'offline',
1334
- common: false,
1335
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-reconciliation']),
1336
- primaryArtefacts: Object.freeze([]),
1337
- planSupport: false,
1338
- additionalOutputFormats: Object.freeze([]),
1339
- bootstrapProfile: 'allowed',
1340
- }),
1341
- timeline: commandSeed({
1342
- reference: {
1343
- description: 'Build an ordered same-domain history from saved Lookup observations.',
1344
- example: 'whoisleuth timeline first.json second.json latest.json --json',
1345
- boundary: 'The command is offline, accepts 2 to 20 bounded inputs for one domain, retains no filenames or raw registry payloads, and does not treat changed collection conditions as a domain change.',
1346
- },
1347
- collection: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
1348
- summary: 'Build same-domain history from saved lookups',
1349
- options: ['--json', '--quiet', '--no-color'],
1350
- positionals: Object.freeze([positional('sources', 'file', 2, 20)]),
1351
- constraints: EMPTY_CONSTRAINTS,
1352
- handlerOwner: 'inline',
1353
- networkEffect: 'offline',
1354
- common: false,
1355
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-timeline']),
1356
- primaryArtefacts: Object.freeze(['Bounded retained-observation timeline']),
1357
- planSupport: false,
1358
- additionalOutputFormats: Object.freeze([]),
1359
- bootstrapProfile: 'allowed',
1360
- }),
1361
- export: commandSeed({
1362
- reference: {
1363
- description: 'Convert one saved lookup into a versioned evidence report.',
1364
- example: 'whoisleuth export lookup.json --markdown',
1365
- boundary: `Saved Lookup versions 1 and 2 are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schemas ${PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
1366
- },
1367
- collection: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
1368
- summary: 'Convert a lookup to an evidence report',
1369
- options: ['--markdown', '--html', '--compact', '--no-attribution'],
1370
- positionals: OPTIONAL_FILE_POSITIONAL,
1371
- constraints: Object.freeze([
1372
- constraint({ kind: 'mutually_exclusive', options: ['--markdown', '--html'] }),
1373
- constraint({ kind: 'excludes_all', option: '--compact', excludedOptions: ['--markdown', '--html'] }),
1374
- constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
1375
- ]),
1376
- handlerOwner: 'inline',
1377
- networkEffect: 'offline',
1378
- common: true,
1379
- schemaIdentifiers: Object.freeze(['whoisleuth.lookup-evidence']),
1380
- primaryArtefacts: Object.freeze(['Portable evidence report']),
1381
- planSupport: false,
1382
- additionalOutputFormats: Object.freeze(['JSON']),
1383
- bootstrapProfile: 'allowed',
1384
- }),
1385
- });
1386
- const COMMAND_ORDER = Object.freeze(Object.keys(COMMAND_SEEDS));
1387
19
  const HELP_COMMANDS_BY_GROUP = Object.freeze(Object.fromEntries(CLI_HELP_GROUP_ORDER.map((group) => [
1388
20
  group,
1389
21
  Object.freeze(COMMAND_ORDER.filter((command) => CLI_COMMAND_SEMANTICS[command].group === group)),
@@ -1469,11 +101,11 @@ function generatedCommandUsage(command, options, positionals, constraints) {
1469
101
  }
1470
102
  const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) => {
1471
103
  const seed = COMMAND_SEEDS[command];
1472
- const commonOptions = Object.freeze([...commonOptionsSeedForCommand(command)]);
104
+ const commonOptions = Object.freeze([...commonOptionsForSeed(seed)]);
1473
105
  const commandOptions = seed.options;
1474
106
  const grammarOptions = Object.freeze([
1475
- ...commonOptions.map((option) => optionSpec(command, option, 'common')),
1476
- ...commandOptions.map((option) => optionSpec(command, option, 'command')),
107
+ ...commonOptions.map((option) => optionSpec(command, option, 'common', commandOptionDefinition(command, option))),
108
+ ...commandOptions.map((option) => optionSpec(command, option, 'command', commandOptionDefinition(command, option))),
1477
109
  ]);
1478
110
  const constraints = grammarConstraints(commandOptions, seed.constraints);
1479
111
  return Object.freeze({
@@ -1498,7 +130,7 @@ const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) =>
1498
130
  metaActions: Object.freeze(['help']),
1499
131
  }),
1500
132
  execution: Object.freeze({
1501
- handlerOwner: seed.handlerOwner,
133
+ handlerOwner: inlineCommandFamily(command) ? 'inline' : seed.handlerOwner,
1502
134
  networkEffect: seed.networkEffect,
1503
135
  }),
1504
136
  help: Object.freeze({
@@ -1621,6 +253,41 @@ function commandHelp(command) {
1621
253
  const collection = COMMAND_COLLECTION[command];
1622
254
  const values = commandDefinition(command).grammar.options.filter(expandedOptionValues)
1623
255
  .map(option => `\n${option.option} values:\n${option.values.map(value => ` ${value}`).join('\n')}\n`).join('');
1624
- return `WHOISleuth ${command}\n${detail.description}\n\nUsage:\n ${COMMAND_USAGE[command]}\n\nExample:\n ${detail.example}\n\nCollection:\n ${collection.mode === 'offline' ? 'Offline' : 'Network'}: ${collection.scope}\n\nBoundary:\n ${detail.boundary}\n${values}\nRun "whoisleuth --help" to see the grouped command list.\n`;
256
+ const options = commandOptionHelp(command).map(option => {
257
+ const range = option.ranges.map(item => `${item.whenOptionPresent ? `With ${item.whenOptionPresent}: ` : 'Range: '}${item.minimum}–${item.maximum}.`).join(' ');
258
+ const policies = option.option === '--fail-on' ? ` Values: ${option.values.join(', ')}.` : '';
259
+ return ` ${option.usage}\n ${option.description}${option.defaultDescription ? ` Default: ${option.defaultDescription}.` : ''}${option.repeatable ? ' May be repeated.' : ''}${range ? ` ${range}` : ''}${policies}`;
260
+ }).join('\n');
261
+ return `WHOISleuth ${command}\n${detail.description}\n\nExample:\n ${detail.example}\n\nUsage:\n ${COMMAND_USAGE[command]}\n\nOptions:\n${options}\n\nCollection:\n ${collection.mode === 'offline' ? 'Offline' : 'Network'}: ${collection.scope}\n\nBoundary:\n ${detail.boundary}\n${values}\nRun "whoisleuth --help" to see the grouped command list.\n`;
262
+ }
263
+ export function commandDefaultNumber(command, option, deep = false) {
264
+ const value = commandOptionDefinition(command, option).defaultValue(command, deep);
265
+ if (!commandOwnsOption(command, option) || typeof value !== 'number')
266
+ throw new TypeError(`No numeric default for ${command} ${option}.`);
267
+ return value;
268
+ }
269
+ export function commandDefaultText(command, option) {
270
+ const value = commandOptionDefinition(command, option).defaultValue(command, false);
271
+ if (!commandOwnsOption(command, option) || typeof value !== 'string')
272
+ throw new TypeError(`No text default for ${command} ${option}.`);
273
+ return value;
274
+ }
275
+ // Help is a mechanical projection, not another parser or public grammar format.
276
+ export function commandOptionHelp(command) {
277
+ return commandDefinition(command).grammar.options.map(specification => {
278
+ const owner = commandOptionDefinition(command, specification.option);
279
+ const ordinary = owner.defaultValue(command, false);
280
+ const deep = owner.defaultValue(command, true);
281
+ return Object.freeze({
282
+ option: specification.option,
283
+ scope: specification.scope,
284
+ usage: optionUsage(specification),
285
+ description: owner.description(command),
286
+ values: specification.values,
287
+ repeatable: specification.occurrence === 'repeatable',
288
+ ranges: specification.integerRanges,
289
+ defaultDescription: ordinary === null ? null : ordinary === deep ? String(ordinary) : `${ordinary} in Fast mode; ${deep} in Deep mode`,
290
+ });
291
+ });
1625
292
  }
1626
- export { CLI_COMMAND_REGISTRY, CLI_CASE_OPERATIONS, CLI_COMMANDS, CLI_META_ACTIONS, COMMAND_COLLECTION, COMMAND_DESCRIPTIONS, COMMAND_DETAILS, COMMAND_USAGE, FILE_POSITIONAL_COMMANDS, HELP, HELP_COMMANDS_BY_GROUP, INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES, OPTIONS_BY_COMMAND, cliMetaActionForInvocation, cliInvocationOptionIndices, cliInvocationNetworkEffect, commandOptionSpec, commandOwnsOption, commandPositionalSpecs, commonOptionsForCommand, commandDefinition, commandHelp, isCliCommand, metaActionDefinition, };
293
+ export { INLINE_COMMAND_FAMILIES, inlineCommandFamily, inlineCommandsFor, CLI_COMMAND_REGISTRY, CLI_CASE_OPERATIONS, CLI_INDICATOR_OPERATIONS, CLI_COMMANDS, CLI_META_ACTIONS, COMMAND_COLLECTION, COMMAND_DESCRIPTIONS, COMMAND_DETAILS, COMMAND_USAGE, FILE_POSITIONAL_COMMANDS, HELP, HELP_COMMANDS_BY_GROUP, INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES, OPTIONS_BY_COMMAND, cliMetaActionForInvocation, cliInvocationOptionIndices, cliInvocationNetworkEffect, commandOptionSpec, commandOwnsOption, commandPositionalSpecs, commonOptionsForCommand, commandDefinition, commandHelp, isCliCommand, metaActionDefinition, };