@slicedearth/whoisleuth-cli 2.0.1 → 2.1.0

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 (95) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +16 -9
  37. package/docs/cli.md +4 -2
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  59. package/lib/registrar-standing-contract.mjs +300 -0
  60. package/lib/registrar-standing.mjs +269 -0
  61. package/lib/registry-insights.mjs +2 -8
  62. package/lib/risk-scoring.mjs +26 -10
  63. package/lib/sslbl-certificates.generated.mjs +138 -136
  64. package/lib/structured-data-identity.mjs +1 -3
  65. package/lib/technology-evidence-role.mjs +51 -0
  66. package/lib/threat-intelligence-runtime.mjs +3 -3
  67. package/lib/threat-intelligence-types.mjs +13 -0
  68. package/lib/threatfox-intelligence.mjs +1 -3
  69. package/lib/tls-intelligence.mjs +1 -2
  70. package/lib/urlhaus-intelligence.mjs +1 -3
  71. package/lib/urlscan-intelligence.mjs +1 -3
  72. package/lib/website-security-posture.mjs +1 -2
  73. package/lib/website-technology.mjs +72 -23
  74. package/lib/whois-common-formats.mjs +276 -0
  75. package/lib/whois-dialects.mjs +534 -0
  76. package/lib/whois-parser-support.mjs +305 -0
  77. package/lib/whois-parser.mjs +135 -0
  78. package/lib/whois.mjs +6 -1187
  79. package/package.json +2 -2
  80. package/packages/cases/case-evidence-model.mjs +9 -0
  81. package/packages/cases/case-migration-model.mjs +17 -11
  82. package/packages/cases/case-response-model.mjs +13 -9
  83. package/packages/cases/case-response-packet.mjs +297 -112
  84. package/packages/comparison/page-similarity.mjs +23 -0
  85. package/packages/contracts/case-portability.mjs +225 -61
  86. package/packages/contracts/cli-lookup.mjs +4 -3
  87. package/packages/contracts/domain-control-manifest.mjs +1 -1
  88. package/packages/contracts/investigation-portability.mjs +1 -1
  89. package/packages/contracts/risk-calibration.mjs +2 -2
  90. package/packages/contracts/workspace-portability.mjs +34 -6
  91. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  92. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  93. package/packages/workspace/website-snapshot-model.mjs +79 -6
  94. package/third-party-notices.txt +1 -1
  95. package/cli/artifact-structure.mjs +0 -2507
@@ -1,56 +1,16 @@
1
1
  import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
2
- import { LOOKUP_EVIDENCE_SCHEMA_VERSION, PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../lib/evidence-export.mjs";
2
+ import { LOOKUP_EVIDENCE_SCHEMA_VERSION, PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../lib/evidence-export.mjs";
3
3
  import { RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA, } from "../packages/contracts/risk-calibration.mjs";
4
- import { CASE_SCHEMA_VERSION, PUBLIC_WORKSPACE_ARCHIVE_VERSION, WORKSPACE_ARCHIVE_VERSION, } from "../packages/contracts/case-portability.mjs";
5
- const COMMAND_ORDER = Object.freeze([
6
- 'completion',
7
- 'doctor',
8
- 'commands',
9
- 'manual',
10
- 'manifest',
11
- 'map-observations',
12
- 'oam-export',
13
- 'lookup',
14
- 'bulk',
15
- 'ct-search',
16
- 'ct-intake',
17
- 'discover',
18
- 'discover-scan',
19
- 'posture',
20
- 'http',
21
- 'tls',
22
- 'dnssec-validate',
23
- 'mail-transport',
24
- 'registry-support',
25
- 'registry-doctor',
26
- 'registry-cohort',
27
- 'registry-scaffold',
28
- 'risk-calibrate',
29
- 'lookalike-calibrate',
30
- 'verify-artifact',
31
- 'interchange-report',
32
- 'inspect-archive',
33
- 'sign-artifact',
34
- 'verify-signature',
35
- 'source-report',
36
- 'compare',
37
- 'page-compare',
38
- 'mail-review',
39
- 'review-evidence',
40
- 'brief',
41
- 'case-pack',
42
- 'domain-control',
43
- 'monitor-once',
44
- 'assurance',
45
- 'change-packet',
46
- 'sharing-review',
47
- 'workflow-plan',
48
- 'workflow-run',
49
- 'diff',
50
- 'reconcile',
51
- 'timeline',
52
- 'export',
53
- ]);
4
+ import { CASE_SCHEMA_VERSION, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, WORKSPACE_ARCHIVE_VERSION, } from "../packages/contracts/case-portability.mjs";
5
+ import { CLI_FAIL_POLICIES_BY_COMMAND } from "./fail-policy.mjs";
6
+ const LEGACY_WORKSPACE_ARCHIVE_VERSIONS = SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS
7
+ .filter((version) => version !== WORKSPACE_ARCHIVE_VERSION);
8
+ const LEGACY_WORKSPACE_ARCHIVE_DESCRIPTION = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
9
+ .map((version) => `version-${version}`)
10
+ .join(' and ');
11
+ const LEGACY_WORKSPACE_ARCHIVE_SCOPE = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
12
+ .map((version) => `v${version}`)
13
+ .join(' and ');
54
14
  const INVESTIGATION_PLAN_RECIPES = Object.freeze([
55
15
  'domain-triage',
56
16
  'lookalike-review',
@@ -120,395 +80,11 @@ must be requested explicitly and can disclose a target to additional sources.
120
80
  Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
121
81
  Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
122
82
  `;
123
- const COMMAND_USAGE_SEED = Object.freeze({
124
- completion: 'whoisleuth completion <bash|zsh|fish|powershell>',
125
- doctor: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
126
- commands: 'whoisleuth commands [--common] [--group <group>] [--mode <offline|network>] [--json] [--quiet] [--no-color]',
127
- manual: 'whoisleuth manual',
128
- manifest: 'whoisleuth manifest <artefact.json> [...] --workflow <label> [--configuration-digest <sha256:digest>] [--json] [--quiet] [--no-color]',
129
- 'map-observations': 'whoisleuth map-observations [mapping.json] [--json] [--quiet] [--no-color]',
130
- 'oam-export': 'whoisleuth oam-export [external-findings.json] [--json] [--quiet] [--no-color]',
131
- lookup: 'whoisleuth lookup [domain|IP|ASN] [--json|--junit|--markdown|--html] [--no-attribution] [--fast|--deep] [--observer <label>] [--vantage <label>] [--plan] [--summary|--verbose|--browse [--save-lookup <file>]] [--palette <auto|light|dark>] [--strict-exit] [--fail-on <policies>] [--events] [--quiet] [--no-color]',
132
- bulk: 'whoisleuth bulk [file] [--json|--jsonl|--junit|--csv|--domains|--queries] [--registered-only|--inconclusive-only|--errors-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events] [--plan] [--fail-on <policies>] [--quiet] [--no-color]',
133
- 'ct-search': 'whoisleuth ct-search [keyword] [--json] [--quiet] [--no-color]',
134
- 'ct-intake': 'whoisleuth ct-intake [events.json] [--json] [--quiet] [--no-color]',
135
- discover: 'whoisleuth discover [brand|domain] [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains] [--quiet] [--no-color]',
136
- 'discover-scan': 'whoisleuth discover-scan [brand|domain] [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--fast|--deep] [--scan-limit <n>] [--chunk-size <n>] [--concurrency <n>] [--resolver <IPs>] [--allowlist <file>] [--checkpoint <file> [--resume]] [--observation-snapshot <file>] [--registered-only|--inconclusive-only|--acquisition-only|--suppressed-only] [--events] [--json|--jsonl|--csv|--domains] [--plan] [--fail-on <policies>] [--quiet] [--no-color]',
137
- posture: 'whoisleuth posture [domain] [--selectors <list>] [--retired-selectors <list>] [--mail-profile <profile>] [--json|--sarif --owned-domain] [--quiet] [--no-color]',
138
- http: 'whoisleuth http [domain] [--json] [--quiet] [--no-color]',
139
- tls: 'whoisleuth tls [hostname] [--json] [--quiet] [--no-color]',
140
- 'dnssec-validate': 'whoisleuth dnssec-validate <domain> --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized [--json] [--quiet] [--no-color]',
141
- 'mail-transport': 'whoisleuth mail-transport [input.json] --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized --active-probe [--json] [--quiet] [--no-color]',
142
- 'registry-support': 'whoisleuth registry-support [domain|suffix] [--json] [--quiet] [--no-color]',
143
- 'registry-doctor': 'whoisleuth registry-doctor [lookup.json] [--json] [--quiet] [--no-color]',
144
- 'registry-cohort': 'whoisleuth registry-cohort [lookups-or-reports.json|jsonl] [--json] [--quiet] [--no-color]',
145
- 'registry-scaffold': 'whoisleuth registry-scaffold --profile <id> --suffix <suffix> --scenario <registered|not_found|inconclusive>',
146
- 'risk-calibrate': 'whoisleuth risk-calibrate [dataset.json] [--json|--summary-json] [--quiet] [--no-color]',
147
- 'lookalike-calibrate': 'whoisleuth lookalike-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
148
- 'verify-artifact': 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--manifest <manifest.json> --manifest-entry <artifact-N>] [--json] [--strict-exit] [--quiet] [--no-color]',
149
- 'interchange-report': 'whoisleuth interchange-report [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
150
- 'inspect-archive': 'whoisleuth inspect-archive [archive.json] [--passphrase-file <file>] [--search <value>] [--require-match] [--reveal] [--expect-content-digest <sha256:digest>] [--json] [--quiet] [--no-color]',
151
- 'sign-artifact': 'whoisleuth sign-artifact [artifact.json] --private-key-file <file>',
152
- 'verify-signature': 'whoisleuth verify-signature [package.json] [--public-key-file <file>] [--json] [--quiet] [--no-color]',
153
- 'source-report': 'whoisleuth source-report [lookup.json] [--json] [--quiet] [--no-color]',
154
- compare: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
155
- 'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
156
- 'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
157
- 'review-evidence': 'whoisleuth review-evidence [evidence.json] [--mmdb <database-file>] [--json] [--strict-exit] [--quiet] [--no-color]',
158
- brief: 'whoisleuth brief [lookup.json] [--json] [--quiet] [--no-color]',
159
- 'case-pack': 'whoisleuth case-pack [cases.json] --audience <internal|trusted|public> --reviewed [--json] [--quiet] [--no-color]',
160
- 'domain-control': 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
161
- 'monitor-once': 'whoisleuth monitor-once [manifest.json] [--previous <snapshot.json>] [--limit <1-20>] [--concurrency <1-3>] [--fail-on <policies>] [--json|--junit] [--quiet] [--no-color]',
162
- assurance: 'whoisleuth assurance [assurance-input.json] [--json] [--quiet] [--no-color]',
163
- 'change-packet': 'whoisleuth change-packet [change-packet-input.json] [--json] [--quiet] [--no-color]',
164
- 'sharing-review': 'whoisleuth sharing-review [artifact.json] --marking <level> --recipient-scope <scope> --purpose <text> [--human-reviewed] [--personal-data-reviewed] [--redactions-confirmed] [--json] [--quiet] [--no-color]',
165
- 'workflow-plan': 'whoisleuth workflow-plan <recipe> <domain|brand> | --list | --explain <recipe> [--json] [--quiet] [--no-color]',
166
- 'workflow-run': 'whoisleuth workflow-run <recipe> <domain|brand> [--approve-network] [--resume <state.json>] [--json] [--quiet] [--no-color]',
167
- diff: 'whoisleuth diff <left.json> <right.json> [--left-session <id> --right-session <id>] [--json] [--quiet] [--no-color]',
168
- reconcile: 'whoisleuth reconcile <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
169
- timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
170
- export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact] [--no-attribution]',
171
- });
172
- const COMMAND_DETAILS_SEED = Object.freeze({
173
- completion: {
174
- description: 'Print a static shell-completion script for the installed CLI.',
175
- example: 'whoisleuth completion zsh > ~/.zfunc/_whoisleuth',
176
- boundary: 'Generation is offline and writes only the script to stdout. The command never modifies shell configuration.',
177
- },
178
- doctor: {
179
- description: 'Check the supported runtime and local terminal capabilities.',
180
- example: 'whoisleuth doctor --json',
181
- boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
182
- },
183
- commands: {
184
- description: 'List the installed command contracts in terminal or versioned JSON form.',
185
- example: 'whoisleuth commands --json',
186
- boundary: 'Catalogue generation is offline. It reports declared command modes and limits without executing collection or inspecting local evidence.',
187
- },
188
- manual: {
189
- description: 'Print a generated roff manual page for local installation.',
190
- example: 'whoisleuth manual | man -l -',
191
- boundary: 'Generation is offline and derives from the same command catalogue as focused help.',
192
- },
193
- manifest: {
194
- description: 'Record an ordered, path-free manifest for up to 16 local JSON artefacts.',
195
- example: 'whoisleuth manifest lookup.json comparison.json --workflow "domain review" --json',
196
- boundary: 'The command records hashes and bounded schema metadata only. It omits source paths and artefact contents and performs no network collection.',
197
- },
198
- 'map-observations': {
199
- description: 'Apply one bounded declarative field-mapping profile to local source observations.',
200
- example: 'whoisleuth map-observations mapping.json --json',
201
- boundary: 'Profiles select allowlisted dotted fields only. They execute no scripts, make no requests, and emit the browser-compatible external-findings contract.',
202
- },
203
- 'oam-export': {
204
- description: 'Project browser-compatible external findings into a bounded Open Asset Model bridge document.',
205
- example: 'whoisleuth oam-export external-findings.json --json',
206
- boundary: 'The projection is offline, preserves source completeness without inventing confidence, and covers only bounded FQDN, IP address, certificate, and related edge vocabulary.',
207
- },
208
- lookup: {
209
- description: 'Collect registration evidence for one domain, IP, or ASN.',
210
- example: 'whoisleuth lookup example.test --deep --browse',
211
- 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. 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.',
212
- },
213
- bulk: {
214
- description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
215
- example: 'cat domains.txt | whoisleuth bulk --jsonl',
216
- 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.',
217
- },
218
- 'ct-search': {
219
- description: 'Search certificate-transparency observations for one bounded keyword.',
220
- example: 'whoisleuth ct-search "example brand" --json',
221
- boundary: 'Certificate observations do not prove website activity, registration ownership, or malicious intent.',
222
- },
223
- 'ct-intake': {
224
- description: 'Normalise source-qualified local certificate events into browser-compatible findings.',
225
- example: 'whoisleuth ct-intake certificate-events.json --json',
226
- 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.',
227
- },
228
- discover: {
229
- description: 'Generate bounded lookalike-domain candidates from local mutation rules.',
230
- example: 'whoisleuth discover example.test --preset common --jsonl',
231
- boundary: 'Generation and optional local snapshot comparison are offline. Candidates are leads only and are not resolved, registered, or classified as malicious.',
232
- },
233
- 'discover-scan': {
234
- description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
235
- example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
236
- 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.',
237
- },
238
- posture: {
239
- description: 'Review bounded DNS mail, delegation, and domain-control posture.',
240
- example: 'whoisleuth posture example.test --mail-profile standard --json',
241
- boundary: 'Missing or failed DNS observations remain inconclusive and are not reported as absent controls.',
242
- },
243
- http: {
244
- description: 'Inspect one homepage request, redirects, and bounded response metadata.',
245
- example: 'whoisleuth http example.test --json',
246
- 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.',
247
- },
248
- tls: {
249
- description: 'Inspect one hostname certificate through a bounded TLS connection.',
250
- example: 'whoisleuth tls example.test --json',
251
- boundary: 'One observed connection is point-in-time evidence and does not establish every address, edge, or historical certificate.',
252
- },
253
- 'dnssec-validate': {
254
- description: 'Cryptographically validate one authorised DNSSEC chain from a supplied trust anchor through one selected public resolver.',
255
- example: 'whoisleuth dnssec-validate example.test --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --json',
256
- 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.',
257
- },
258
- 'mail-transport': {
259
- description: 'Review selected authorised MX endpoints, DNSSEC-qualified TLSA evidence, SMTP capabilities, and optional STARTTLS certificates.',
260
- example: 'whoisleuth mail-transport selected-mx.json --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --active-probe --json',
261
- 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.',
262
- },
263
- 'registry-support': {
264
- description: 'Explain the local registry capability profile for one domain or suffix.',
265
- example: 'whoisleuth registry-support example.test --json',
266
- boundary: 'This command is offline. Catalogue coverage does not test live reachability or decide registration or availability.',
267
- },
268
- 'registry-doctor': {
269
- description: 'Compare a saved Lookup registry result with the reviewed local capability profile.',
270
- example: 'whoisleuth registry-doctor lookup.json --json',
271
- boundary: 'The command is offline. It distinguishes expected access constraints from collection results and does not contact a live registry.',
272
- },
273
- 'registry-cohort': {
274
- description: 'Build privacy-safe suffix and capability-profile timelines from saved observations or retained cohort reports.',
275
- example: 'whoisleuth registry-cohort saved-lookups.jsonl --json',
276
- boundary: 'This command is offline and omits domains, queries, and raw evidence. Input families cannot be mixed, and retained samples are never assumed independent.',
277
- },
278
- 'registry-scaffold': {
279
- description: 'Create a bounded synthetic WHOIS fixture scaffold for one existing capability profile.',
280
- example: 'whoisleuth registry-scaffold --profile example-profile --suffix test --scenario registered',
281
- 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.',
282
- },
283
- 'risk-calibrate': {
284
- description: 'Replay reviewed labels against the current explainable Risk model.',
285
- example: 'whoisleuth risk-calibrate calibration.json --summary-json',
286
- 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.',
287
- },
288
- 'lookalike-calibrate': {
289
- description: 'Summarise reviewed candidate dispositions by mutation family without retaining domains.',
290
- example: 'whoisleuth lookalike-calibrate reviewed-candidates.json --json',
291
- boundary: 'Calibration is offline and diagnostic. It omits candidate identifiers, domains, notes, and evidence and never tunes generation or filtering automatically.',
292
- },
293
- 'verify-artifact': {
294
- description: 'Validate a supported archive, claim passport, packet, manifest, saved Lookup, or supported Lookup-evidence export without printing evidence contents.',
295
- example: 'whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit',
296
- boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file; --strict-exit returns 4 when only an envelope or legacy projection integrity was verified.',
297
- },
298
- 'interchange-report': {
299
- description: 'Report what one recognised portable artefact preserves, excludes, and supports across browser and CLI workflows.',
300
- example: 'whoisleuth interchange-report workspace.json --json',
301
- boundary: 'The report is offline and metadata-only. It does not echo targets, contacts, notes, passphrases, evidence values, or an unrecognised schema string.',
302
- },
303
- 'inspect-archive': {
304
- description: `Summarise or search one current version-${WORKSPACE_ARCHIVE_VERSION} workspace archive, with exact public version-${PUBLIC_WORKSPACE_ARCHIVE_VERSION} support and redacted output by default.`,
305
- example: 'whoisleuth inspect-archive workspace.json --search example.test --json',
306
- boundary: 'Exact matches require --reveal. Retired and future archive versions are rejected without changing data. The archive is read locally and is never uploaded.',
307
- },
308
- 'sign-artifact': {
309
- description: 'Sign one reviewed response packet or supported manifest with a local private key.',
310
- example: 'whoisleuth sign-artifact packet.json --private-key-file analyst-private.pem',
311
- boundary: 'The command never creates, stores, or transmits keys. Key custody and signer identity remain the operator\'s responsibility.',
312
- },
313
- 'verify-signature': {
314
- description: 'Verify the cryptographic signature of one signed evidence package and report embedded-artefact assurance separately.',
315
- example: 'whoisleuth verify-signature packet.signed.json --json',
316
- boundary: 'A valid signature proves package consistency for the embedded key. It does not upgrade failed or unsupported embedded-artefact assurance or establish the holder\'s real-world identity or authority.',
317
- },
318
- 'source-report': {
319
- description: 'Create a target-free reliability summary from a saved lookup.',
320
- example: 'whoisleuth source-report lookup.json --json',
321
- boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
322
- },
323
- compare: {
324
- description: 'Compare separately attributed registry publications in a saved lookup.',
325
- example: 'whoisleuth compare lookup.json --json',
326
- boundary: 'Comparison is offline. Differences are review context and do not by themselves prove which publication is current.',
327
- },
328
- 'page-compare': {
329
- description: 'Compare static page identity, favicon, technology, and TLS evidence in two saved deep lookups.',
330
- example: 'whoisleuth page-compare official.json candidate.json --json',
331
- boundary: 'Comparison is offline and component-based. It executes no page code and produces no aggregate similarity or maliciousness score.',
332
- },
333
- 'mail-review': {
334
- description: 'Review passive MX, null MX, SPF, DMARC, and shared mail-provider evidence from saved Bulk results.',
335
- example: 'whoisleuth mail-review candidates.json --json',
336
- boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
337
- },
338
- 'review-evidence': {
339
- description: 'Review one versioned DNS, domain-change, routing, GeoIP, RDAP, or trust-store document offline.',
340
- example: 'whoisleuth review-evidence domain-change.json --json --strict-exit',
341
- boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
342
- },
343
- brief: {
344
- description: 'Turn one saved Lookup into a compact decision brief with facts, unknowns, contradictions, and next actions.',
345
- example: 'whoisleuth brief lookup.json --json',
346
- boundary: 'The command is offline, excludes raw upstream payloads, and does not create an analyst assertion or claim that the saved observation is current.',
347
- },
348
- 'case-pack': {
349
- description: `Build a reviewed, audience-specific Case-pack v2 from an exact Case-schema-${CASE_SCHEMA_VERSION} export.`,
350
- example: 'whoisleuth case-pack cases.json --audience trusted --reviewed --json',
351
- boundary: 'The command is offline, creates a new package, never mutates the source archive, and requires an explicit review acknowledgement.',
352
- },
353
- 'domain-control': {
354
- description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
355
- example: 'whoisleuth domain-control domain-control-input.json --json',
356
- boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
357
- },
358
- 'monitor-once': {
359
- description: 'Collect one bounded owned-domain review and compare it with an optional prior checkpoint.',
360
- example: 'whoisleuth monitor-once manifest.json --previous previous.json --json --output next.json',
361
- 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.',
362
- },
363
- assurance: {
364
- description: 'Review a versioned domain change, recovery-dependency, or retirement plan.',
365
- example: 'whoisleuth assurance domain-assurance.json --json',
366
- boundary: 'The command is offline and treats every provider label, readiness state, and evidence reference as analyst-authored input. It changes no configuration.',
367
- },
368
- 'change-packet': {
369
- description: 'Assemble pre-change, post-change, and planning evidence into one integrity-protected packet.',
370
- example: 'whoisleuth change-packet change-review.json --json',
371
- boundary: 'Assembly is offline. Readiness reflects only the supplied bounded evidence and does not authorise or perform a domain change.',
372
- },
373
- 'sharing-review': {
374
- description: 'Lint one reviewed artefact against local integrity, marking, recipient, personal-data, and redaction controls.',
375
- example: 'whoisleuth sharing-review packet.json --marking amber --recipient-scope organization --purpose "Reviewed incident handoff" --human-reviewed --personal-data-reviewed --redactions-confirmed --json',
376
- 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.',
377
- },
378
- 'workflow-plan': {
379
- description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
380
- example: 'whoisleuth workflow-plan domain-triage example.test --json',
381
- boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
382
- },
383
- 'workflow-run': {
384
- description: 'Execute approved concrete steps from a fixed investigation recipe and emit a resumable checkpoint.',
385
- example: 'whoisleuth workflow-run domain-triage example.test --approve-network --json --output run.json',
386
- boundary: 'Only installed recipe commands can run. Network steps require explicit approval for each invocation, and analyst-selection placeholders always pause without interpretation.',
387
- },
388
- diff: {
389
- description: 'Compare two compatible retained Lookup, Bulk-session, or domain-portfolio artefacts.',
390
- example: 'whoisleuth diff first.json second.json --json',
391
- boundary: 'Comparison is offline. Multi-session Bulk exports require explicit session IDs, and missing, unavailable, equal, and different evidence remain separate states.',
392
- },
393
- reconcile: {
394
- description: 'Reconcile bounded values across independently labelled observations of one domain.',
395
- example: 'whoisleuth reconcile office.json mobile.json external.json --json',
396
- 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.',
397
- },
398
- timeline: {
399
- description: 'Build an ordered same-domain history from saved Lookup observations.',
400
- example: 'whoisleuth timeline first.json second.json latest.json --json',
401
- 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.',
402
- },
403
- export: {
404
- description: 'Convert one saved lookup into a versioned evidence report.',
405
- example: 'whoisleuth export lookup.json --markdown',
406
- 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; exact public schema ${PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remains readable with its strict source/publication binding, 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.`,
407
- },
408
- });
409
- const COMMAND_COLLECTION_SEED = Object.freeze({
410
- completion: { mode: 'offline', scope: 'Prints one static script and changes no shell configuration.' },
411
- doctor: { mode: 'network', scope: 'Network access is opt-in with --network and is limited to fixed public DNS, HTTPS, and WHOIS diagnostics.' },
412
- commands: { mode: 'offline', scope: 'Reads the embedded command catalogue and performs no collection.' },
413
- manual: { mode: 'offline', scope: 'Builds documentation from the embedded command catalogue.' },
414
- manifest: { mode: 'offline', scope: 'Reads 1 to 16 local JSON artefacts capped at 32 MiB in total and retains no source paths.' },
415
- 'map-observations': { mode: 'offline', scope: 'Reads one mapping document capped at 4 MiB and executes no scripts or requests.' },
416
- 'oam-export': { mode: 'offline', scope: 'Reads one browser-compatible external-findings document and projects bounded graph records locally.' },
417
- lookup: { mode: 'network', scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.' },
418
- bulk: { mode: 'network', scope: 'Accepts at most 500 fast or 50 deep targets, with concurrency capped at 8 fast or 3 deep.' },
419
- 'ct-search': { mode: 'network', scope: 'Accepts one bounded search keyword and queries the fixed certificate-transparency source.' },
420
- 'ct-intake': { mode: 'offline', scope: 'Reads one source-qualified event batch capped at 4 MiB and makes no request.' },
421
- discover: { mode: 'offline', scope: 'Generates a bounded candidate set from local rules, dictionaries, and optional saved snapshots.' },
422
- 'discover-scan': { mode: 'network', scope: 'Scans at most 500 fast or 50 deep candidates, with concurrency capped at 8 fast or 3 deep.' },
423
- posture: { mode: 'network', scope: 'Accepts one domain and performs bounded RDAP, DNS, and conditional MTA-STS HTTPS requests.' },
424
- http: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
425
- tls: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
426
- 'dnssec-validate': { 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.' },
427
- 'mail-transport': { mode: 'network', scope: 'Accepts at most three selected authorised MX hosts, uses one public resolver, and performs sequential bounded SMTP connections with no retries.' },
428
- 'registry-support': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
429
- 'registry-doctor': { mode: 'offline', scope: 'Reads one saved Lookup and the embedded registry capability catalogue.' },
430
- 'registry-cohort': { mode: 'offline', scope: 'Reads at most 500 saved Lookups or retained cohort reports from one unmixed family and emits bounded target-free timelines.' },
431
- 'registry-scaffold': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue and prints one synthetic fixture template.' },
432
- 'risk-calibrate': { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
433
- 'lookalike-calibrate': { mode: 'offline', scope: 'Reads at most 5,000 reviewed candidate labels from one dataset capped at 2 MiB.' },
434
- 'verify-artifact': { mode: 'offline', scope: 'Reads one selected bounded artefact and, when explicitly supplied, one manifest whose selected entry is compared by exact bytes and canonical identity.' },
435
- 'interchange-report': { mode: 'offline', scope: 'Reads one selected bounded portable artefact and emits fixed compatibility metadata only.' },
436
- 'inspect-archive': { mode: 'offline', scope: `Reads one selected bounded workspace archive v${WORKSPACE_ARCHIVE_VERSION}, retains exact v${PUBLIC_WORKSPACE_ARCHIVE_VERSION} compatibility, and redacts output by default.` },
437
- 'sign-artifact': { mode: 'offline', scope: 'Reads one selected artefact and one local private key without transmitting either.' },
438
- 'verify-signature': { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
439
- 'source-report': { mode: 'offline', scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
440
- compare: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
441
- 'page-compare': { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
442
- 'mail-review': { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
443
- 'review-evidence': { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
444
- brief: { mode: 'offline', scope: 'Reads one bounded saved Lookup and emits a compact source-attributed decision brief.' },
445
- 'case-pack': { mode: 'offline', scope: `Reads one bounded Case-schema-${CASE_SCHEMA_VERSION} browser export and writes a separate audience-specific Case-pack v2.` },
446
- 'domain-control': { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
447
- 'monitor-once': { mode: 'network', scope: 'Runs deep collection for at most 20 manifest domains with concurrency capped at 3.' },
448
- assurance: { mode: 'offline', scope: 'Reads one versioned plan capped at 2 MiB and makes no request or configuration change.' },
449
- 'change-packet': { mode: 'offline', scope: 'Reads one versioned packet input capped at 6 MiB and makes no request or configuration change.' },
450
- 'sharing-review': { mode: 'offline', scope: 'Reads one artefact capped at 15 MiB, emits only bounded schema/version metadata and no content values, and performs no transmission.' },
451
- 'workflow-plan': { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
452
- 'workflow-run': { mode: 'network', scope: 'Runs only concrete fixed-recipe steps; network collection requires --approve-network and analyst-selection steps always pause.' },
453
- diff: { mode: 'offline', scope: 'Reads two compatible retained artefacts capped at 8 MiB each and retains no source paths.' },
454
- reconcile: { mode: 'offline', scope: 'Reads 2 to 5 saved observations for one domain, capped at 32 MiB in total.' },
455
- timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
456
- export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
457
- });
458
83
  const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force', '--config', '--profile', '--palette']);
459
84
  const REGISTRY_SCAFFOLD_COMMON_OPTIONS = Object.freeze(COMMON_OPTIONS.filter((option) => option !== '--config' && option !== '--profile'));
460
85
  function commonOptionsSeedForCommand(command) {
461
86
  return command === 'registry-scaffold' ? REGISTRY_SCAFFOLD_COMMON_OPTIONS : COMMON_OPTIONS;
462
87
  }
463
- const OPTIONS_BY_COMMAND_SEED = Object.freeze({
464
- manifest: ['--workflow', '--configuration-digest', '--json', '--quiet', '--no-color'],
465
- 'map-observations': ['--json', '--quiet', '--no-color'],
466
- 'oam-export': ['--json', '--quiet', '--no-color'],
467
- lookup: ['--json', '--junit', '--markdown', '--html', '--no-attribution', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--browse', '--save-lookup', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
468
- bulk: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--plan', '--fail-on', '--quiet', '--no-color'],
469
- 'ct-search': ['--json', '--quiet', '--no-color'],
470
- 'ct-intake': ['--json', '--quiet', '--no-color'],
471
- discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
472
- 'discover-scan': ['--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', '--domains', '--quiet', '--no-color'],
473
- posture: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
474
- http: ['--json', '--quiet', '--no-color'],
475
- tls: ['--json', '--quiet', '--no-color'],
476
- 'dnssec-validate': ['--resolver', '--trust-anchor', '--owned-or-authorized', '--json', '--quiet', '--no-color'],
477
- 'mail-transport': ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe', '--json', '--quiet', '--no-color'],
478
- 'registry-support': ['--json', '--quiet', '--no-color'],
479
- 'registry-doctor': ['--json', '--quiet', '--no-color'],
480
- 'registry-cohort': ['--json', '--quiet', '--no-color'],
481
- 'registry-scaffold': ['--profile', '--suffix', '--scenario'],
482
- 'risk-calibrate': ['--json', '--summary-json', '--quiet', '--no-color'],
483
- 'lookalike-calibrate': ['--json', '--quiet', '--no-color'],
484
- 'verify-artifact': ['--passphrase-file', '--manifest', '--manifest-entry', '--json', '--strict-exit', '--quiet', '--no-color'],
485
- 'interchange-report': ['--passphrase-file', '--json', '--quiet', '--no-color'],
486
- 'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--expect-content-digest', '--json', '--quiet', '--no-color'],
487
- 'sign-artifact': ['--private-key-file'],
488
- 'verify-signature': ['--public-key-file', '--json', '--quiet', '--no-color'],
489
- 'source-report': ['--json', '--quiet', '--no-color'],
490
- compare: ['--json', '--quiet', '--no-color'],
491
- 'page-compare': ['--json', '--quiet', '--no-color'],
492
- 'mail-review': ['--json', '--quiet', '--no-color'],
493
- 'review-evidence': ['--mmdb', '--json', '--strict-exit', '--quiet', '--no-color'],
494
- brief: ['--json', '--quiet', '--no-color'],
495
- 'case-pack': ['--audience', '--reviewed', '--json', '--quiet', '--no-color'],
496
- 'domain-control': ['--json', '--quiet', '--no-color'],
497
- 'monitor-once': ['--previous', '--limit', '--concurrency', '--fail-on', '--json', '--junit', '--quiet', '--no-color'],
498
- assurance: ['--json', '--quiet', '--no-color'],
499
- 'change-packet': ['--json', '--quiet', '--no-color'],
500
- 'sharing-review': ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
501
- 'workflow-plan': ['--list', '--explain', '--json', '--quiet', '--no-color'],
502
- 'workflow-run': ['--approve-network', '--resume', '--json', '--quiet', '--no-color'],
503
- diff: ['--left-session', '--right-session', '--json', '--quiet', '--no-color'],
504
- reconcile: ['--json', '--quiet', '--no-color'],
505
- timeline: ['--json', '--quiet', '--no-color'],
506
- export: ['--markdown', '--html', '--compact', '--no-attribution'],
507
- completion: [],
508
- commands: ['--common', '--group', '--mode', '--json', '--quiet', '--no-color'],
509
- doctor: ['--network', '--json', '--quiet', '--no-color'],
510
- manual: [],
511
- });
512
88
  const VALUE_OPTIONS = Object.freeze({
513
89
  '--group': Object.freeze(['investigate', 'respond', 'assure', 'utilities']),
514
90
  '--mode': Object.freeze(['offline', 'network']),
@@ -560,61 +136,6 @@ function positional(name, valueKind, minimum, maximum, values = [], inputSource
560
136
  const NO_POSITIONALS = Object.freeze([]);
561
137
  const OPTIONAL_FILE_POSITIONAL = Object.freeze([positional('source', 'file', 0, 1, [], 'argv_or_stdin')]);
562
138
  const OPTIONAL_TEXT_POSITIONAL = Object.freeze([positional('subject', 'text', 0, 1, [], 'argv_or_stdin')]);
563
- const POSITIONALS_BY_COMMAND_SEED = Object.freeze({
564
- completion: Object.freeze([positional('shell', 'enum', 1, 1, ['bash', 'zsh', 'fish', 'powershell'])]),
565
- doctor: NO_POSITIONALS,
566
- commands: NO_POSITIONALS,
567
- manual: NO_POSITIONALS,
568
- manifest: Object.freeze([positional('artefacts', 'file', 1, 16)]),
569
- 'map-observations': OPTIONAL_FILE_POSITIONAL,
570
- 'oam-export': OPTIONAL_FILE_POSITIONAL,
571
- lookup: Object.freeze([positional('target', 'text', 0, 1, [], 'argv_or_stdin', ['--browse'])]),
572
- bulk: OPTIONAL_FILE_POSITIONAL,
573
- 'ct-search': Object.freeze([positional('keyword', 'text', 0, 1, [], 'argv_or_stdin')]),
574
- 'ct-intake': OPTIONAL_FILE_POSITIONAL,
575
- discover: OPTIONAL_TEXT_POSITIONAL,
576
- 'discover-scan': OPTIONAL_TEXT_POSITIONAL,
577
- posture: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
578
- http: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
579
- tls: Object.freeze([positional('hostname', 'text', 0, 1, [], 'argv_or_stdin')]),
580
- 'dnssec-validate': Object.freeze([positional('domain', 'text', 1, 1)]),
581
- 'mail-transport': OPTIONAL_FILE_POSITIONAL,
582
- 'registry-support': Object.freeze([positional('domain-or-suffix', 'text', 0, 1, [], 'argv_or_stdin')]),
583
- 'registry-doctor': OPTIONAL_FILE_POSITIONAL,
584
- 'registry-cohort': OPTIONAL_FILE_POSITIONAL,
585
- 'registry-scaffold': NO_POSITIONALS,
586
- 'risk-calibrate': OPTIONAL_FILE_POSITIONAL,
587
- 'lookalike-calibrate': OPTIONAL_FILE_POSITIONAL,
588
- 'verify-artifact': OPTIONAL_FILE_POSITIONAL,
589
- 'interchange-report': OPTIONAL_FILE_POSITIONAL,
590
- 'inspect-archive': OPTIONAL_FILE_POSITIONAL,
591
- 'sign-artifact': OPTIONAL_FILE_POSITIONAL,
592
- 'verify-signature': OPTIONAL_FILE_POSITIONAL,
593
- 'source-report': OPTIONAL_FILE_POSITIONAL,
594
- compare: OPTIONAL_FILE_POSITIONAL,
595
- 'page-compare': Object.freeze([positional('sources', 'file', 2, 2)]),
596
- 'mail-review': OPTIONAL_FILE_POSITIONAL,
597
- 'review-evidence': OPTIONAL_FILE_POSITIONAL,
598
- brief: OPTIONAL_FILE_POSITIONAL,
599
- 'case-pack': OPTIONAL_FILE_POSITIONAL,
600
- 'domain-control': OPTIONAL_FILE_POSITIONAL,
601
- 'monitor-once': OPTIONAL_FILE_POSITIONAL,
602
- assurance: OPTIONAL_FILE_POSITIONAL,
603
- 'change-packet': OPTIONAL_FILE_POSITIONAL,
604
- 'sharing-review': OPTIONAL_FILE_POSITIONAL,
605
- 'workflow-plan': Object.freeze([
606
- positional('recipe', 'enum', 0, 1, INVESTIGATION_PLAN_RECIPES),
607
- positional('subject', 'text', 0, 1),
608
- ]),
609
- 'workflow-run': Object.freeze([
610
- positional('recipe', 'enum', 1, 1, RUNNABLE_INVESTIGATION_PLAN_RECIPES),
611
- positional('subject', 'text', 1, 1),
612
- ]),
613
- diff: Object.freeze([positional('sources', 'file', 2, 2)]),
614
- reconcile: Object.freeze([positional('sources', 'file', 2, 5)]),
615
- timeline: Object.freeze([positional('sources', 'file', 2, 20)]),
616
- export: OPTIONAL_FILE_POSITIONAL,
617
- });
618
139
  const TEXT_OPTIONS = Object.freeze([
619
140
  '--families',
620
141
  '--workflow',
@@ -629,6 +150,7 @@ const TEXT_OPTIONS = Object.freeze([
629
150
  '--observer',
630
151
  '--retired-selectors',
631
152
  '--search',
153
+ '--select',
632
154
  '--selectors',
633
155
  '--tlds',
634
156
  '--vantage',
@@ -640,6 +162,7 @@ const IDEMPOTENT_OPTIONS = Object.freeze([
640
162
  '--quiet',
641
163
  '--redactions-confirmed',
642
164
  ]);
165
+ const REPEATABLE_OPTIONS = Object.freeze(['--select']);
643
166
  const INTEGER_RANGE_SEED = Object.freeze({
644
167
  bulk: Object.freeze({
645
168
  '--concurrency': Object.freeze([
@@ -693,88 +216,6 @@ const MACHINE_OUTPUT_OPTIONS = Object.freeze([
693
216
  '--json', '--jsonl', '--junit', '--csv', '--domains', '--queries', '--markdown',
694
217
  '--html', '--sarif', '--summary-json',
695
218
  ]);
696
- const GRAMMAR_CONSTRAINTS_SEED = Object.freeze({
697
- commands: Object.freeze([]),
698
- manifest: Object.freeze([
699
- constraint({ kind: 'required', options: ['--workflow'] }),
700
- ]),
701
- lookup: Object.freeze([
702
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit', '--markdown', '--html'] }),
703
- constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
704
- constraint({ kind: 'mutually_exclusive', options: ['--summary', '--verbose'] }),
705
- constraint({ kind: 'requires_all', option: '--save-lookup', requiredOptions: ['--browse'] }),
706
- constraint({ kind: 'excludes_all', option: '--summary', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
707
- constraint({ kind: 'excludes_all', option: '--verbose', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
708
- constraint({ kind: 'excludes_all', option: '--browse', excludedOptions: ['--json', '--junit', '--markdown', '--html', '--summary', '--verbose', '--events', '--plan', '--quiet'] }),
709
- constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
710
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--junit', '--markdown', '--html', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--fail-on'] }),
711
- ]),
712
- bulk: Object.freeze([
713
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries'] }),
714
- constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--errors-only'] }),
715
- constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
716
- constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
717
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--junit', '--csv', '--domains', '--queries', '--events', '--checkpoint', '--resume', '--quiet', '--fail-on'] }),
718
- ]),
719
- discover: Object.freeze([
720
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--domains'] }),
721
- constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
722
- constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
723
- ]),
724
- 'discover-scan': Object.freeze([
725
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--csv', '--domains'] }),
726
- constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
727
- constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
728
- constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only'] }),
729
- constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
730
- constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
731
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--csv', '--domains', '--events', '--checkpoint', '--resume', '--observation-snapshot', '--quiet', '--fail-on'] }),
732
- ]),
733
- posture: Object.freeze([
734
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--sarif'] }),
735
- constraint({ kind: 'requires_all', option: '--sarif', requiredOptions: ['--owned-domain'] }),
736
- ]),
737
- 'dnssec-validate': Object.freeze([
738
- constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized'] }),
739
- ]),
740
- 'mail-transport': Object.freeze([
741
- constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe'] }),
742
- ]),
743
- 'registry-scaffold': Object.freeze([
744
- constraint({ kind: 'required', options: ['--profile', '--suffix', '--scenario'] }),
745
- ]),
746
- 'risk-calibrate': Object.freeze([
747
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--summary-json'] }),
748
- ]),
749
- 'verify-artifact': Object.freeze([
750
- constraint({ kind: 'requires_all', option: '--manifest', requiredOptions: ['--manifest-entry'] }),
751
- constraint({ kind: 'requires_all', option: '--manifest-entry', requiredOptions: ['--manifest'] }),
752
- ]),
753
- 'inspect-archive': Object.freeze([
754
- constraint({ kind: 'requires_all', option: '--reveal', requiredOptions: ['--search'] }),
755
- constraint({ kind: 'requires_all', option: '--require-match', requiredOptions: ['--search'] }),
756
- ]),
757
- 'sign-artifact': Object.freeze([
758
- constraint({ kind: 'required', options: ['--private-key-file'] }),
759
- ]),
760
- 'case-pack': Object.freeze([
761
- constraint({ kind: 'required', options: ['--audience', '--reviewed'] }),
762
- ]),
763
- 'monitor-once': Object.freeze([
764
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit'] }),
765
- ]),
766
- 'sharing-review': Object.freeze([
767
- constraint({ kind: 'required', options: ['--marking', '--recipient-scope', '--purpose'] }),
768
- ]),
769
- 'workflow-plan': Object.freeze([
770
- constraint({ kind: 'mutually_exclusive', options: ['--list', '--explain'] }),
771
- ]),
772
- export: Object.freeze([
773
- constraint({ kind: 'mutually_exclusive', options: ['--markdown', '--html'] }),
774
- constraint({ kind: 'excludes_all', option: '--compact', excludedOptions: ['--markdown', '--html'] }),
775
- constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
776
- ]),
777
- });
778
219
  function optionValueKind(command, option) {
779
220
  if (Object.hasOwn(VALUE_OPTIONS, option))
780
221
  return option === '--fail-on' ? 'policy_list' : 'enum';
@@ -788,19 +229,23 @@ function optionValueKind(command, option) {
788
229
  }
789
230
  function optionSpec(command, option, scope) {
790
231
  const valueKind = optionValueKind(command, option);
232
+ const values = option === '--fail-on'
233
+ ? CLI_FAIL_POLICIES_BY_COMMAND[command] ?? []
234
+ : VALUE_OPTIONS[option] ?? [];
791
235
  return Object.freeze({
792
236
  option,
793
237
  scope,
794
238
  arity: valueKind === 'flag' ? 0 : 1,
795
239
  valueKind,
796
- values: Object.freeze([...(VALUE_OPTIONS[option] ?? [])]),
240
+ values: Object.freeze([...values]),
797
241
  integerRanges: Object.freeze([...(INTEGER_RANGE_SEED[command]?.[option] ?? [])]),
798
- occurrence: IDEMPOTENT_OPTIONS.includes(option) ? 'idempotent' : 'once',
242
+ occurrence: REPEATABLE_OPTIONS.includes(option)
243
+ ? 'repeatable'
244
+ : IDEMPOTENT_OPTIONS.includes(option) ? 'idempotent' : 'once',
799
245
  metaAction: option === '--help' ? 'help' : null,
800
246
  });
801
247
  }
802
- function grammarConstraints(command) {
803
- const commandOptions = OPTIONS_BY_COMMAND_SEED[command];
248
+ function grammarConstraints(commandOptions, commandConstraints) {
804
249
  const machineOutputOptions = commandOptions.filter((option) => MACHINE_OUTPUT_OPTIONS.includes(option));
805
250
  return Object.freeze([
806
251
  ...FILE_OUTPUT_CONSTRAINTS,
@@ -814,251 +259,1140 @@ function grammarConstraints(command) {
814
259
  ...(commandOptions.includes('--browse')
815
260
  ? [constraint({ kind: 'mutually_exclusive', options: ['--browse', '--output'] })]
816
261
  : []),
817
- ...(GRAMMAR_CONSTRAINTS_SEED[command] ?? EMPTY_CONSTRAINTS),
262
+ ...commandConstraints,
818
263
  ]);
819
264
  }
820
- const COMMAND_DESCRIPTIONS_SEED = Object.freeze({
821
- manifest: 'Build an evidence manifest offline',
822
- 'map-observations': 'Apply a declarative observation map offline',
823
- 'oam-export': 'Project external findings to Open Asset Model',
824
- lookup: 'Collect one domain, IP, or ASN',
825
- bulk: 'Run bounded multi-target collection',
826
- 'ct-search': 'Search certificate observations',
827
- 'ct-intake': 'Normalise certificate observations offline',
828
- discover: 'Generate lookalike candidates offline',
829
- 'discover-scan': 'Collect a supervised candidate review queue',
830
- posture: 'Review DNS and mail posture',
831
- http: 'Inspect one homepage request',
832
- tls: 'Inspect one TLS connection',
833
- 'dnssec-validate': 'Validate an authorised DNSSEC chain',
834
- 'mail-transport': 'Review selected authorised SMTP transports',
835
- 'registry-support': 'Explain local registry coverage',
836
- 'registry-doctor': 'Diagnose saved registry collection',
837
- 'registry-cohort': 'Build target-free registry quality timelines',
838
- 'registry-scaffold': 'Create a sanitised registry fixture scaffold',
839
- 'risk-calibrate': 'Replay reviewed Risk labels offline',
840
- 'lookalike-calibrate': 'Summarise reviewed lookalike yield offline',
841
- 'verify-artifact': 'Validate saved evidence offline',
842
- 'interchange-report': 'Report portable artefact fidelity offline',
843
- 'inspect-archive': 'Inspect an archive locally',
844
- 'sign-artifact': 'Sign a reviewed artefact locally',
845
- 'verify-signature': 'Verify a signed evidence package',
846
- 'source-report': 'Build a target-free source report',
847
- compare: 'Compare registry publications in one lookup',
848
- 'page-compare': 'Compare saved static page evidence',
849
- 'mail-review': 'Review saved passive mail evidence',
850
- 'review-evidence': 'Review supplied evidence offline',
851
- brief: 'Build a decision brief from a saved lookup',
852
- 'case-pack': 'Build a reviewed case package',
853
- 'domain-control': 'Build or review a domain control manifest',
854
- 'monitor-once': 'Run one bounded domain control review',
855
- assurance: 'Review domain change, recovery, or retirement plans',
856
- 'change-packet': 'Build a reviewed change packet offline',
857
- 'sharing-review': 'Lint an artefact before deliberate sharing',
858
- 'workflow-plan': 'Plan a fixed investigation recipe',
859
- 'workflow-run': 'Execute approved fixed-recipe steps',
860
- diff: 'Compare two compatible retained artefacts',
861
- reconcile: 'Reconcile independently labelled observations',
862
- timeline: 'Build same-domain history from saved lookups',
863
- export: 'Convert a lookup to an evidence report',
864
- completion: 'Print shell completion',
865
- commands: 'List installed command contracts',
866
- doctor: 'Check the local CLI runtime',
867
- manual: 'Print the generated manual page',
868
- });
869
- const HELP_COMMANDS_BY_GROUP = Object.freeze({
870
- investigate: Object.freeze([
871
- 'lookup', 'bulk', 'ct-search', 'ct-intake', 'discover', 'discover-scan',
872
- 'posture', 'http', 'tls', 'registry-support', 'registry-doctor',
873
- 'registry-cohort', 'source-report', 'compare', 'page-compare', 'mail-review',
874
- 'review-evidence', 'brief',
875
- ]),
876
- respond: Object.freeze([
877
- 'map-observations', 'oam-export', 'case-pack', 'change-packet',
878
- 'sharing-review', 'export',
879
- ]),
880
- assure: Object.freeze([
881
- 'dnssec-validate', 'mail-transport', 'domain-control', 'monitor-once',
882
- 'assurance', 'workflow-plan', 'workflow-run', 'diff', 'reconcile', 'timeline',
883
- 'inspect-archive', 'verify-artifact', 'interchange-report', 'manifest',
884
- 'sign-artifact', 'verify-signature', 'risk-calibrate', 'lookalike-calibrate',
885
- ]),
886
- utilities: Object.freeze([
887
- 'registry-scaffold', 'doctor', 'commands', 'completion', 'manual',
888
- ]),
889
- });
890
- const HELP_GROUP_BY_COMMAND = Object.freeze(Object.fromEntries(Object.entries(HELP_COMMANDS_BY_GROUP).flatMap(([group, commands]) => (commands.map((command) => [command, group])))));
891
- const HANDLER_OWNER_BY_COMMAND = Object.freeze({
892
- completion: 'inline',
893
- doctor: 'inline',
894
- commands: 'inline',
895
- manual: 'inline',
896
- manifest: 'inline',
897
- 'map-observations': 'inline',
898
- 'oam-export': 'inline',
899
- lookup: 'lookup',
900
- bulk: 'bulk',
901
- 'ct-search': 'network',
902
- 'ct-intake': 'inline',
903
- discover: 'discovery',
904
- 'discover-scan': 'discovery_scan',
905
- posture: 'network',
906
- http: 'network',
907
- tls: 'network',
908
- 'dnssec-validate': 'network',
909
- 'mail-transport': 'network',
910
- 'registry-support': 'inline',
911
- 'registry-doctor': 'inline',
912
- 'registry-cohort': 'inline',
913
- 'registry-scaffold': 'inline',
914
- 'risk-calibrate': 'inline',
915
- 'lookalike-calibrate': 'inline',
916
- 'verify-artifact': 'inline',
917
- 'interchange-report': 'inline',
918
- 'inspect-archive': 'evidence',
919
- 'sign-artifact': 'evidence',
920
- 'verify-signature': 'evidence',
921
- 'source-report': 'inline',
922
- compare: 'inline',
923
- 'page-compare': 'inline',
924
- 'mail-review': 'inline',
925
- 'review-evidence': 'inline',
926
- brief: 'inline',
927
- 'case-pack': 'inline',
928
- 'domain-control': 'inline',
929
- 'monitor-once': 'inline',
930
- assurance: 'inline',
931
- 'change-packet': 'inline',
932
- 'sharing-review': 'inline',
933
- 'workflow-plan': 'inline',
934
- 'workflow-run': 'inline',
935
- diff: 'inline',
936
- reconcile: 'inline',
937
- timeline: 'inline',
938
- export: 'inline',
939
- });
940
- const NETWORK_EFFECT_BY_COMMAND = Object.freeze({
941
- completion: 'offline',
942
- doctor: 'conditional_network',
943
- commands: 'offline',
944
- manual: 'offline',
945
- manifest: 'offline',
946
- 'map-observations': 'offline',
947
- 'oam-export': 'offline',
948
- lookup: 'conditional_network',
949
- bulk: 'conditional_network',
950
- 'ct-search': 'always_network',
951
- 'ct-intake': 'offline',
952
- discover: 'offline',
953
- 'discover-scan': 'conditional_network',
954
- posture: 'always_network',
955
- http: 'always_network',
956
- tls: 'always_network',
957
- 'dnssec-validate': 'always_network',
958
- 'mail-transport': 'always_network',
959
- 'registry-support': 'offline',
960
- 'registry-doctor': 'offline',
961
- 'registry-cohort': 'offline',
962
- 'registry-scaffold': 'offline',
963
- 'risk-calibrate': 'offline',
964
- 'lookalike-calibrate': 'offline',
965
- 'verify-artifact': 'offline',
966
- 'interchange-report': 'offline',
967
- 'inspect-archive': 'offline',
968
- 'sign-artifact': 'offline',
969
- 'verify-signature': 'offline',
970
- 'source-report': 'offline',
971
- compare: 'offline',
972
- 'page-compare': 'offline',
973
- 'mail-review': 'offline',
974
- 'review-evidence': 'offline',
975
- brief: 'offline',
976
- 'case-pack': 'offline',
977
- 'domain-control': 'offline',
978
- 'monitor-once': 'always_network',
979
- assurance: 'offline',
980
- 'change-packet': 'offline',
981
- 'sharing-review': 'offline',
982
- 'workflow-plan': 'offline',
983
- 'workflow-run': 'conditional_network',
984
- diff: 'offline',
985
- reconcile: 'offline',
986
- timeline: 'offline',
987
- export: 'offline',
988
- });
989
- const COMMON_COMMANDS = Object.freeze([
990
- 'doctor', 'commands', 'lookup', 'bulk', 'discover', 'discover-scan', 'verify-artifact',
991
- 'review-evidence', 'case-pack', 'workflow-plan', 'diff', 'export',
992
- ]);
993
- const SCHEMA_IDENTIFIERS_BY_COMMAND = Object.freeze({
994
- commands: Object.freeze(['whoisleuth.cli.command-catalogue']),
995
- doctor: Object.freeze(['whoisleuth.cli.doctor']),
996
- manifest: Object.freeze(['whoisleuth.investigation-manifest']),
997
- 'map-observations': Object.freeze(['whoisleuth.external-observation-mapping']),
998
- 'oam-export': Object.freeze(['whoisleuth.open-asset-model-bridge']),
999
- lookup: Object.freeze(['whoisleuth.cli.lookup', 'whoisleuth.cli.lookup-plan']),
1000
- bulk: Object.freeze(['whoisleuth.cli.bulk', 'whoisleuth.cli.bulk.item', 'whoisleuth.cli.bulk-checkpoint']),
1001
- 'ct-search': Object.freeze(['whoisleuth.cli.ct-search']),
1002
- 'ct-intake': Object.freeze(['whoisleuth.ct-event-batch', 'whoisleuth.external-findings']),
1003
- discover: Object.freeze(['whoisleuth.cli.discover', 'whoisleuth.cli.discover.item', 'whoisleuth.cli.discovery-snapshot']),
1004
- 'discover-scan': Object.freeze(['whoisleuth.cli.discovery-scan', 'whoisleuth.cli.discovery-scan.item', 'whoisleuth.cli.discovery-observation-snapshot']),
1005
- posture: Object.freeze(['whoisleuth.cli.posture']),
1006
- http: Object.freeze(['whoisleuth.cli.http']),
1007
- tls: Object.freeze(['whoisleuth.cli.tls']),
1008
- 'dnssec-validate': Object.freeze(['whoisleuth.dnssec-chain-validation', 'whoisleuth.dnssec-trust-anchor']),
1009
- 'mail-transport': Object.freeze(['whoisleuth.mail-transport.input', 'whoisleuth.cli.mail-transport-review']),
1010
- 'registry-support': Object.freeze(['whoisleuth.cli.registry-support', 'whoisleuth.registry-standards-coverage']),
1011
- 'registry-doctor': Object.freeze(['whoisleuth.cli.registry-doctor']),
1012
- 'registry-cohort': Object.freeze(['whoisleuth.cli.registry-cohort']),
1013
- 'risk-calibrate': Object.freeze([RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA]),
1014
- 'lookalike-calibrate': Object.freeze(['whoisleuth.lookalike-calibration-input', 'whoisleuth.lookalike-calibration']),
1015
- 'verify-artifact': Object.freeze(['whoisleuth.offline-artifact-verification']),
1016
- 'interchange-report': Object.freeze(['whoisleuth.interchange-fidelity-report']),
1017
- 'inspect-archive': Object.freeze(['whoisleuth.workspace-archive-inspection']),
1018
- 'sign-artifact': Object.freeze(['whoisleuth.signed-evidence-package']),
1019
- 'verify-signature': Object.freeze(['whoisleuth.evidence-signature-verification']),
1020
- 'source-report': Object.freeze(['whoisleuth.source-reliability-report']),
1021
- compare: Object.freeze(['whoisleuth.cli.compare']),
1022
- 'page-compare': Object.freeze(['whoisleuth.cli.page-compare']),
1023
- 'mail-review': Object.freeze(['whoisleuth.cli.mail-review']),
1024
- 'review-evidence': Object.freeze([
1025
- 'whoisleuth.cli.offline-evidence-review',
1026
- 'whoisleuth.rdap-search-input',
1027
- 'whoisleuth.dnssec-evidence-input',
1028
- 'whoisleuth.tlsa-evidence-input',
1029
- 'whoisleuth.rpki-route-input',
1030
- 'whoisleuth.local-geoip-query',
1031
- 'whoisleuth.encrypted-dns-plan-input',
1032
- ]),
1033
- brief: Object.freeze(['whoisleuth.cli.lookup-brief']),
1034
- 'case-pack': Object.freeze(['whoisleuth.cli.case-pack', 'whoisleuth.case-report']),
1035
- 'domain-control': Object.freeze(['whoisleuth.cli.domain-control-review-input', 'whoisleuth.cli.domain-control-review']),
1036
- 'monitor-once': Object.freeze(['whoisleuth.cli.domain-control-monitor', 'whoisleuth.domain-control-flight-recorder.input']),
1037
- assurance: Object.freeze(['whoisleuth.domain-assurance.input', 'whoisleuth.domain-assurance']),
1038
- 'change-packet': Object.freeze(['whoisleuth.domain-change-packet.input', 'whoisleuth.domain-change-packet']),
1039
- 'sharing-review': Object.freeze(['whoisleuth.cli.sharing-review']),
1040
- 'workflow-plan': Object.freeze(['whoisleuth.cli.investigation-plan', 'whoisleuth.cli.workflow-recipe-catalogue']),
1041
- 'workflow-run': Object.freeze(['whoisleuth.cli.investigation-run']),
1042
- diff: Object.freeze(['whoisleuth.cli.lookup-diff']),
1043
- reconcile: Object.freeze(['whoisleuth.cli.lookup-reconciliation']),
1044
- timeline: Object.freeze(['whoisleuth.cli.lookup-timeline']),
1045
- export: Object.freeze(['whoisleuth.lookup-evidence']),
1046
- });
1047
- const PRIMARY_ARTEFACTS_BY_COMMAND = Object.freeze({
1048
- lookup: Object.freeze(['Source-qualified Lookup', 'Lookup request plan']),
1049
- bulk: Object.freeze(['Bulk result', 'Bulk checkpoint']),
1050
- discover: Object.freeze(['Candidate set', 'Discovery snapshot']),
1051
- 'discover-scan': Object.freeze(['Reviewed candidate queue', 'Observation snapshot']),
1052
- 'verify-artifact': Object.freeze(['Offline verification report']),
1053
- 'case-pack': Object.freeze(['Reviewed Case-pack v2']),
1054
- 'workflow-plan': Object.freeze(['Plan-only workflow document']),
1055
- 'workflow-run': Object.freeze(['Resumable workflow state']),
1056
- diff: Object.freeze(['Retained-evidence comparison']),
1057
- timeline: Object.freeze(['Bounded retained-observation timeline']),
1058
- export: Object.freeze(['Portable evidence report']),
265
+ function commandSeed(seed) {
266
+ return Object.freeze({
267
+ ...seed,
268
+ reference: Object.freeze({ ...seed.reference }),
269
+ collection: Object.freeze({ ...seed.collection }),
270
+ options: Object.freeze([...seed.options]),
271
+ positionals: Object.freeze([...seed.positionals]),
272
+ constraints: Object.freeze([...seed.constraints]),
273
+ schemaIdentifiers: Object.freeze([...seed.schemaIdentifiers]),
274
+ primaryArtefacts: Object.freeze([...seed.primaryArtefacts]),
275
+ additionalOutputFormats: Object.freeze([...seed.additionalOutputFormats]),
276
+ });
277
+ }
278
+ const COMMAND_SEEDS = Object.freeze({
279
+ completion: commandSeed({
280
+ reference: {
281
+ usage: 'whoisleuth completion <bash|zsh|fish|powershell>',
282
+ description: 'Print a static shell-completion script for the installed CLI.',
283
+ example: 'whoisleuth completion zsh > ~/.zfunc/_whoisleuth',
284
+ boundary: 'Generation is offline and writes only the script to stdout. The command never modifies shell configuration.',
285
+ },
286
+ collection: { mode: 'offline', scope: 'Prints one static script and changes no shell configuration.' },
287
+ summary: 'Print shell completion',
288
+ options: [],
289
+ positionals: Object.freeze([positional('shell', 'enum', 1, 1, ['bash', 'zsh', 'fish', 'powershell'])]),
290
+ constraints: EMPTY_CONSTRAINTS,
291
+ helpGroup: 'utilities',
292
+ handlerOwner: 'inline',
293
+ networkEffect: 'offline',
294
+ common: false,
295
+ schemaIdentifiers: Object.freeze([]),
296
+ primaryArtefacts: Object.freeze([]),
297
+ planSupport: false,
298
+ additionalOutputFormats: Object.freeze([]),
299
+ bootstrapProfile: 'allowed',
300
+ }),
301
+ doctor: commandSeed({
302
+ reference: {
303
+ usage: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
304
+ description: 'Check the supported runtime and local terminal capabilities.',
305
+ example: 'whoisleuth doctor --json',
306
+ boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
307
+ },
308
+ collection: { mode: 'network', scope: 'Network access is opt-in with --network and is limited to fixed public DNS, HTTPS, and WHOIS diagnostics.' },
309
+ summary: 'Check the local CLI runtime',
310
+ options: ['--network', '--json', '--quiet', '--no-color'],
311
+ positionals: NO_POSITIONALS,
312
+ constraints: EMPTY_CONSTRAINTS,
313
+ helpGroup: 'utilities',
314
+ handlerOwner: 'inline',
315
+ networkEffect: 'conditional_network',
316
+ common: true,
317
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.doctor']),
318
+ primaryArtefacts: Object.freeze([]),
319
+ planSupport: false,
320
+ additionalOutputFormats: Object.freeze([]),
321
+ bootstrapProfile: 'allowed',
322
+ }),
323
+ commands: commandSeed({
324
+ reference: {
325
+ usage: 'whoisleuth commands [--common] [--group <group>] [--mode <offline|network>] [--json] [--quiet] [--no-color]',
326
+ description: 'List the installed command contracts in terminal or versioned JSON form.',
327
+ example: 'whoisleuth commands --json',
328
+ boundary: 'Catalogue generation is offline. It reports declared command modes and limits without executing collection or inspecting local evidence.',
329
+ },
330
+ collection: { mode: 'offline', scope: 'Reads the embedded command catalogue and performs no collection.' },
331
+ summary: 'List installed command contracts',
332
+ options: ['--common', '--group', '--mode', '--json', '--quiet', '--no-color'],
333
+ positionals: NO_POSITIONALS,
334
+ constraints: Object.freeze([]),
335
+ helpGroup: 'utilities',
336
+ handlerOwner: 'inline',
337
+ networkEffect: 'offline',
338
+ common: true,
339
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.command-catalogue']),
340
+ primaryArtefacts: Object.freeze([]),
341
+ planSupport: false,
342
+ additionalOutputFormats: Object.freeze([]),
343
+ bootstrapProfile: 'allowed',
344
+ }),
345
+ manual: commandSeed({
346
+ reference: {
347
+ usage: 'whoisleuth manual',
348
+ description: 'Print a generated roff manual page for local installation.',
349
+ example: 'whoisleuth manual | man -l -',
350
+ boundary: 'Generation is offline and derives from the same command catalogue as focused help.',
351
+ },
352
+ collection: { mode: 'offline', scope: 'Builds documentation from the embedded command catalogue.' },
353
+ summary: 'Print the generated manual page',
354
+ options: [],
355
+ positionals: NO_POSITIONALS,
356
+ constraints: EMPTY_CONSTRAINTS,
357
+ helpGroup: 'utilities',
358
+ handlerOwner: 'inline',
359
+ networkEffect: 'offline',
360
+ common: false,
361
+ schemaIdentifiers: Object.freeze([]),
362
+ primaryArtefacts: Object.freeze([]),
363
+ planSupport: false,
364
+ additionalOutputFormats: Object.freeze([]),
365
+ bootstrapProfile: 'allowed',
366
+ }),
367
+ manifest: commandSeed({
368
+ reference: {
369
+ usage: 'whoisleuth manifest <artefact.json> [...] --workflow <label> [--configuration-digest <sha256:digest>] [--json] [--quiet] [--no-color]',
370
+ description: 'Record an ordered, path-free manifest for up to 16 local JSON artefacts.',
371
+ example: 'whoisleuth manifest lookup.json comparison.json --workflow "domain review" --json',
372
+ boundary: 'The command records hashes and bounded schema metadata only. It omits source paths and artefact contents and performs no network collection.',
373
+ },
374
+ collection: { mode: 'offline', scope: 'Reads 1 to 16 local JSON artefacts capped at 32 MiB in total and retains no source paths.' },
375
+ summary: 'Build an evidence manifest offline',
376
+ options: ['--workflow', '--configuration-digest', '--json', '--quiet', '--no-color'],
377
+ positionals: Object.freeze([positional('artefacts', 'file', 1, 16)]),
378
+ constraints: Object.freeze([
379
+ constraint({ kind: 'required', options: ['--workflow'] }),
380
+ ]),
381
+ helpGroup: 'assure',
382
+ handlerOwner: 'inline',
383
+ networkEffect: 'offline',
384
+ common: false,
385
+ schemaIdentifiers: Object.freeze(['whoisleuth.investigation-manifest']),
386
+ primaryArtefacts: Object.freeze([]),
387
+ planSupport: false,
388
+ additionalOutputFormats: Object.freeze([]),
389
+ bootstrapProfile: 'allowed',
390
+ }),
391
+ "map-observations": commandSeed({
392
+ reference: {
393
+ usage: 'whoisleuth map-observations [mapping.json] [--json] [--quiet] [--no-color]',
394
+ description: 'Apply one bounded declarative field-mapping profile to local source observations.',
395
+ example: 'whoisleuth map-observations mapping.json --json',
396
+ boundary: 'Profiles select allowlisted dotted fields only. They execute no scripts, make no requests, and emit the browser-compatible external-findings contract.',
397
+ },
398
+ collection: { mode: 'offline', scope: 'Reads one mapping document capped at 4 MiB and executes no scripts or requests.' },
399
+ summary: 'Apply a declarative observation map offline',
400
+ options: ['--json', '--quiet', '--no-color'],
401
+ positionals: OPTIONAL_FILE_POSITIONAL,
402
+ constraints: EMPTY_CONSTRAINTS,
403
+ helpGroup: 'respond',
404
+ handlerOwner: 'inline',
405
+ networkEffect: 'offline',
406
+ common: false,
407
+ schemaIdentifiers: Object.freeze(['whoisleuth.external-observation-mapping']),
408
+ primaryArtefacts: Object.freeze([]),
409
+ planSupport: false,
410
+ additionalOutputFormats: Object.freeze([]),
411
+ bootstrapProfile: 'allowed',
412
+ }),
413
+ "oam-export": commandSeed({
414
+ reference: {
415
+ usage: 'whoisleuth oam-export [external-findings.json] [--json] [--quiet] [--no-color]',
416
+ description: 'Project browser-compatible external findings into a bounded Open Asset Model bridge document.',
417
+ example: 'whoisleuth oam-export external-findings.json --json',
418
+ boundary: 'The projection is offline, preserves source completeness without inventing confidence, and covers only bounded FQDN, IP address, certificate, and related edge vocabulary.',
419
+ },
420
+ collection: { mode: 'offline', scope: 'Reads one browser-compatible external-findings document and projects bounded graph records locally.' },
421
+ summary: 'Project external findings to Open Asset Model',
422
+ options: ['--json', '--quiet', '--no-color'],
423
+ positionals: OPTIONAL_FILE_POSITIONAL,
424
+ constraints: EMPTY_CONSTRAINTS,
425
+ helpGroup: 'respond',
426
+ handlerOwner: 'inline',
427
+ networkEffect: 'offline',
428
+ common: false,
429
+ schemaIdentifiers: Object.freeze(['whoisleuth.open-asset-model-bridge']),
430
+ primaryArtefacts: Object.freeze([]),
431
+ planSupport: false,
432
+ additionalOutputFormats: Object.freeze([]),
433
+ bootstrapProfile: 'allowed',
434
+ }),
435
+ lookup: commandSeed({
436
+ reference: {
437
+ usage: 'whoisleuth lookup [domain|IP|ASN] [--json|--junit|--markdown|--html] [--no-attribution] [--fast|--deep] [--observer <label>] [--vantage <label>] [--plan] [--summary|--verbose|--browse [--save-lookup <file>]] [--palette <auto|light|dark>] [--strict-exit] [--fail-on <policies>] [--events] [--quiet] [--no-color]',
438
+ description: 'Collect registration evidence for one domain, IP, or ASN.',
439
+ example: 'whoisleuth lookup example.test --deep --browse',
440
+ 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. 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.',
441
+ },
442
+ collection: { mode: 'network', scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.' },
443
+ summary: 'Collect one domain, IP, or ASN',
444
+ options: ['--json', '--junit', '--markdown', '--html', '--no-attribution', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--browse', '--save-lookup', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
445
+ positionals: Object.freeze([positional('target', 'text', 0, 1, [], 'argv_or_stdin', ['--browse'])]),
446
+ constraints: Object.freeze([
447
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit', '--markdown', '--html'] }),
448
+ constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
449
+ constraint({ kind: 'mutually_exclusive', options: ['--summary', '--verbose'] }),
450
+ constraint({ kind: 'requires_all', option: '--save-lookup', requiredOptions: ['--browse'] }),
451
+ constraint({ kind: 'excludes_all', option: '--summary', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
452
+ constraint({ kind: 'excludes_all', option: '--verbose', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
453
+ constraint({ kind: 'excludes_all', option: '--browse', excludedOptions: ['--json', '--junit', '--markdown', '--html', '--summary', '--verbose', '--events', '--plan', '--quiet'] }),
454
+ constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
455
+ constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--junit', '--markdown', '--html', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--fail-on'] }),
456
+ ]),
457
+ helpGroup: 'investigate',
458
+ handlerOwner: 'lookup',
459
+ networkEffect: 'conditional_network',
460
+ common: true,
461
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup', 'whoisleuth.cli.lookup-plan']),
462
+ primaryArtefacts: Object.freeze(['Source-qualified Lookup', 'Lookup request plan']),
463
+ planSupport: true,
464
+ additionalOutputFormats: Object.freeze([]),
465
+ bootstrapProfile: 'allowed',
466
+ }),
467
+ bulk: commandSeed({
468
+ reference: {
469
+ usage: 'whoisleuth bulk [file] [--json|--jsonl|--junit|--csv|--domains|--queries] [--registered-only|--inconclusive-only|--errors-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events] [--plan] [--fail-on <policies>] [--quiet] [--no-color]',
470
+ description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
471
+ example: 'cat domains.txt | whoisleuth bulk --jsonl',
472
+ 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.',
473
+ },
474
+ collection: { mode: 'network', scope: 'Accepts at most 500 fast or 50 deep targets, with concurrency capped at 8 fast or 3 deep.' },
475
+ summary: 'Run bounded multi-target collection',
476
+ options: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--plan', '--fail-on', '--quiet', '--no-color'],
477
+ positionals: OPTIONAL_FILE_POSITIONAL,
478
+ constraints: Object.freeze([
479
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries'] }),
480
+ constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--errors-only'] }),
481
+ constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
482
+ constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
483
+ constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--junit', '--csv', '--domains', '--queries', '--events', '--checkpoint', '--resume', '--quiet', '--fail-on'] }),
484
+ ]),
485
+ helpGroup: 'investigate',
486
+ handlerOwner: 'bulk',
487
+ networkEffect: 'conditional_network',
488
+ common: true,
489
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.bulk', 'whoisleuth.cli.bulk.item', 'whoisleuth.cli.bulk-checkpoint']),
490
+ primaryArtefacts: Object.freeze(['Bulk result', 'Bulk checkpoint']),
491
+ planSupport: true,
492
+ additionalOutputFormats: Object.freeze([]),
493
+ bootstrapProfile: 'allowed',
494
+ }),
495
+ "ct-search": commandSeed({
496
+ reference: {
497
+ usage: 'whoisleuth ct-search [keyword] [--json] [--quiet] [--no-color]',
498
+ description: 'Search certificate-transparency observations for one bounded keyword.',
499
+ example: 'whoisleuth ct-search "example brand" --json',
500
+ boundary: 'Certificate observations do not prove website activity, registration ownership, or malicious intent.',
501
+ },
502
+ collection: { mode: 'network', scope: 'Accepts one bounded search keyword and queries the fixed certificate-transparency source.' },
503
+ summary: 'Search certificate observations',
504
+ options: ['--json', '--quiet', '--no-color'],
505
+ positionals: Object.freeze([positional('keyword', 'text', 0, 1, [], 'argv_or_stdin')]),
506
+ constraints: EMPTY_CONSTRAINTS,
507
+ helpGroup: 'investigate',
508
+ handlerOwner: 'network',
509
+ networkEffect: 'always_network',
510
+ common: false,
511
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.ct-search']),
512
+ primaryArtefacts: Object.freeze([]),
513
+ planSupport: false,
514
+ additionalOutputFormats: Object.freeze([]),
515
+ bootstrapProfile: 'allowed',
516
+ }),
517
+ "ct-intake": commandSeed({
518
+ reference: {
519
+ usage: 'whoisleuth ct-intake [events.json] [--json] [--quiet] [--no-color]',
520
+ description: 'Normalise source-qualified local certificate events into browser-compatible findings.',
521
+ example: 'whoisleuth ct-intake certificate-events.json --json',
522
+ 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.',
523
+ },
524
+ collection: { mode: 'offline', scope: 'Reads one source-qualified event batch capped at 4 MiB and makes no request.' },
525
+ summary: 'Normalise certificate observations offline',
526
+ options: ['--json', '--quiet', '--no-color'],
527
+ positionals: OPTIONAL_FILE_POSITIONAL,
528
+ constraints: EMPTY_CONSTRAINTS,
529
+ helpGroup: 'investigate',
530
+ handlerOwner: 'inline',
531
+ networkEffect: 'offline',
532
+ common: false,
533
+ schemaIdentifiers: Object.freeze(['whoisleuth.ct-event-batch', 'whoisleuth.external-findings']),
534
+ primaryArtefacts: Object.freeze([]),
535
+ planSupport: false,
536
+ additionalOutputFormats: Object.freeze([]),
537
+ bootstrapProfile: 'allowed',
538
+ }),
539
+ discover: commandSeed({
540
+ reference: {
541
+ usage: 'whoisleuth discover [brand|domain] [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains] [--quiet] [--no-color]',
542
+ description: 'Generate bounded lookalike-domain candidates from local mutation rules.',
543
+ example: 'whoisleuth discover example.test --preset common --jsonl',
544
+ boundary: 'Generation and optional local snapshot comparison are offline. Candidates are leads only and are not resolved, registered, or classified as malicious.',
545
+ },
546
+ collection: { mode: 'offline', scope: 'Generates a bounded candidate set from local rules, dictionaries, and optional saved snapshots.' },
547
+ summary: 'Generate lookalike candidates offline',
548
+ options: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
549
+ positionals: OPTIONAL_TEXT_POSITIONAL,
550
+ constraints: Object.freeze([
551
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--domains'] }),
552
+ constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
553
+ constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
554
+ ]),
555
+ helpGroup: 'investigate',
556
+ handlerOwner: 'discovery',
557
+ networkEffect: 'offline',
558
+ common: true,
559
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.discover', 'whoisleuth.cli.discover.item', 'whoisleuth.cli.discovery-snapshot']),
560
+ primaryArtefacts: Object.freeze(['Candidate set', 'Discovery snapshot']),
561
+ planSupport: false,
562
+ additionalOutputFormats: Object.freeze([]),
563
+ bootstrapProfile: 'allowed',
564
+ }),
565
+ "discover-scan": commandSeed({
566
+ reference: {
567
+ usage: 'whoisleuth discover-scan [brand|domain] [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--fast|--deep] [--scan-limit <n>] [--chunk-size <n>] [--concurrency <n>] [--resolver <IPs>] [--allowlist <file>] [--checkpoint <file> [--resume]] [--observation-snapshot <file>] [--registered-only|--inconclusive-only|--acquisition-only|--suppressed-only] [--events] [--json|--jsonl|--csv|--domains] [--plan] [--fail-on <policies>] [--quiet] [--no-color]',
568
+ description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
569
+ example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
570
+ 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.',
571
+ },
572
+ collection: { mode: 'network', scope: 'Scans at most 500 fast or 50 deep candidates, with concurrency capped at 8 fast or 3 deep.' },
573
+ summary: 'Collect a supervised candidate review queue',
574
+ 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', '--domains', '--quiet', '--no-color'],
575
+ positionals: OPTIONAL_TEXT_POSITIONAL,
576
+ constraints: Object.freeze([
577
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--csv', '--domains'] }),
578
+ constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
579
+ constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
580
+ constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only'] }),
581
+ constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
582
+ constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
583
+ constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--csv', '--domains', '--events', '--checkpoint', '--resume', '--observation-snapshot', '--quiet', '--fail-on'] }),
584
+ ]),
585
+ helpGroup: 'investigate',
586
+ handlerOwner: 'discovery_scan',
587
+ networkEffect: 'conditional_network',
588
+ common: true,
589
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.discovery-scan', 'whoisleuth.cli.discovery-scan.item', 'whoisleuth.cli.discovery-observation-snapshot']),
590
+ primaryArtefacts: Object.freeze(['Reviewed candidate queue', 'Observation snapshot']),
591
+ planSupport: true,
592
+ additionalOutputFormats: Object.freeze([]),
593
+ bootstrapProfile: 'allowed',
594
+ }),
595
+ posture: commandSeed({
596
+ reference: {
597
+ usage: 'whoisleuth posture [domain] [--selectors <list>] [--retired-selectors <list>] [--mail-profile <profile>] [--json|--sarif --owned-domain] [--quiet] [--no-color]',
598
+ description: 'Review bounded DNS mail, delegation, and domain-control posture.',
599
+ example: 'whoisleuth posture example.test --mail-profile standard --json',
600
+ boundary: 'Missing or failed DNS observations remain inconclusive and are not reported as absent controls.',
601
+ },
602
+ collection: { mode: 'network', scope: 'Accepts one domain and performs bounded RDAP, DNS, and conditional MTA-STS HTTPS requests.' },
603
+ summary: 'Review DNS and mail posture',
604
+ options: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
605
+ positionals: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
606
+ constraints: Object.freeze([
607
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--sarif'] }),
608
+ constraint({ kind: 'requires_all', option: '--sarif', requiredOptions: ['--owned-domain'] }),
609
+ ]),
610
+ helpGroup: 'investigate',
611
+ handlerOwner: 'network',
612
+ networkEffect: 'always_network',
613
+ common: false,
614
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.posture']),
615
+ primaryArtefacts: Object.freeze([]),
616
+ planSupport: false,
617
+ additionalOutputFormats: Object.freeze([]),
618
+ bootstrapProfile: 'allowed',
619
+ }),
620
+ http: commandSeed({
621
+ reference: {
622
+ usage: 'whoisleuth http [domain] [--json] [--quiet] [--no-color]',
623
+ description: 'Inspect one homepage request, redirects, and bounded response metadata.',
624
+ example: 'whoisleuth http example.test --json',
625
+ 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.',
626
+ },
627
+ collection: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
628
+ summary: 'Inspect one homepage request',
629
+ options: ['--json', '--quiet', '--no-color'],
630
+ positionals: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
631
+ constraints: EMPTY_CONSTRAINTS,
632
+ helpGroup: 'investigate',
633
+ handlerOwner: 'network',
634
+ networkEffect: 'always_network',
635
+ common: false,
636
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.http']),
637
+ primaryArtefacts: Object.freeze([]),
638
+ planSupport: false,
639
+ additionalOutputFormats: Object.freeze([]),
640
+ bootstrapProfile: 'allowed',
641
+ }),
642
+ tls: commandSeed({
643
+ reference: {
644
+ usage: 'whoisleuth tls [hostname] [--json] [--quiet] [--no-color]',
645
+ description: 'Inspect one hostname certificate through a bounded TLS connection.',
646
+ example: 'whoisleuth tls example.test --json',
647
+ boundary: 'One observed connection is point-in-time evidence and does not establish every address, edge, or historical certificate.',
648
+ },
649
+ collection: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
650
+ summary: 'Inspect one TLS connection',
651
+ options: ['--json', '--quiet', '--no-color'],
652
+ positionals: Object.freeze([positional('hostname', 'text', 0, 1, [], 'argv_or_stdin')]),
653
+ constraints: EMPTY_CONSTRAINTS,
654
+ helpGroup: 'investigate',
655
+ handlerOwner: 'network',
656
+ networkEffect: 'always_network',
657
+ common: false,
658
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.tls']),
659
+ primaryArtefacts: Object.freeze([]),
660
+ planSupport: false,
661
+ additionalOutputFormats: Object.freeze([]),
662
+ bootstrapProfile: 'allowed',
663
+ }),
664
+ "dnssec-validate": commandSeed({
665
+ reference: {
666
+ usage: 'whoisleuth dnssec-validate <domain> --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized [--json] [--quiet] [--no-color]',
667
+ description: 'Cryptographically validate one authorised DNSSEC chain from a supplied trust anchor through one selected public resolver.',
668
+ example: 'whoisleuth dnssec-validate example.test --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --json',
669
+ 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.',
670
+ },
671
+ 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.' },
672
+ summary: 'Validate an authorised DNSSEC chain',
673
+ options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--json', '--quiet', '--no-color'],
674
+ positionals: Object.freeze([positional('domain', 'text', 1, 1)]),
675
+ constraints: Object.freeze([
676
+ constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized'] }),
677
+ ]),
678
+ helpGroup: 'assure',
679
+ handlerOwner: 'network',
680
+ networkEffect: 'always_network',
681
+ common: false,
682
+ schemaIdentifiers: Object.freeze(['whoisleuth.dnssec-chain-validation', 'whoisleuth.dnssec-trust-anchor']),
683
+ primaryArtefacts: Object.freeze([]),
684
+ planSupport: false,
685
+ additionalOutputFormats: Object.freeze([]),
686
+ bootstrapProfile: 'allowed',
687
+ }),
688
+ "mail-transport": commandSeed({
689
+ reference: {
690
+ usage: 'whoisleuth mail-transport [input.json] --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized --active-probe [--json] [--quiet] [--no-color]',
691
+ description: 'Review selected authorised MX endpoints, DNSSEC-qualified TLSA evidence, SMTP capabilities, and optional STARTTLS certificates.',
692
+ example: 'whoisleuth mail-transport selected-mx.json --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --active-probe --json',
693
+ 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.',
694
+ },
695
+ 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.' },
696
+ summary: 'Review selected authorised SMTP transports',
697
+ options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe', '--json', '--quiet', '--no-color'],
698
+ positionals: OPTIONAL_FILE_POSITIONAL,
699
+ constraints: Object.freeze([
700
+ constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe'] }),
701
+ ]),
702
+ helpGroup: 'assure',
703
+ handlerOwner: 'network',
704
+ networkEffect: 'always_network',
705
+ common: false,
706
+ schemaIdentifiers: Object.freeze(['whoisleuth.mail-transport.input', 'whoisleuth.cli.mail-transport-review']),
707
+ primaryArtefacts: Object.freeze([]),
708
+ planSupport: false,
709
+ additionalOutputFormats: Object.freeze([]),
710
+ bootstrapProfile: 'allowed',
711
+ }),
712
+ "registry-support": commandSeed({
713
+ reference: {
714
+ usage: 'whoisleuth registry-support [domain|suffix] [--json] [--quiet] [--no-color]',
715
+ description: 'Explain the local registry capability profile for one domain or suffix.',
716
+ example: 'whoisleuth registry-support example.test --json',
717
+ boundary: 'This command is offline. Catalogue coverage does not test live reachability or decide registration or availability.',
718
+ },
719
+ collection: { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
720
+ summary: 'Explain local registry coverage',
721
+ options: ['--json', '--quiet', '--no-color'],
722
+ positionals: Object.freeze([positional('domain-or-suffix', 'text', 0, 1, [], 'argv_or_stdin')]),
723
+ constraints: EMPTY_CONSTRAINTS,
724
+ helpGroup: 'investigate',
725
+ handlerOwner: 'inline',
726
+ networkEffect: 'offline',
727
+ common: false,
728
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.registry-support', 'whoisleuth.registry-standards-coverage']),
729
+ primaryArtefacts: Object.freeze([]),
730
+ planSupport: false,
731
+ additionalOutputFormats: Object.freeze([]),
732
+ bootstrapProfile: 'allowed',
733
+ }),
734
+ "registry-doctor": commandSeed({
735
+ reference: {
736
+ usage: 'whoisleuth registry-doctor [lookup.json] [--json] [--quiet] [--no-color]',
737
+ description: 'Compare a saved Lookup registry result with the reviewed local capability profile.',
738
+ example: 'whoisleuth registry-doctor lookup.json --json',
739
+ boundary: 'The command is offline. It distinguishes expected access constraints from collection results and does not contact a live registry.',
740
+ },
741
+ collection: { mode: 'offline', scope: 'Reads one saved Lookup and the embedded registry capability catalogue.' },
742
+ summary: 'Diagnose saved registry collection',
743
+ options: ['--json', '--quiet', '--no-color'],
744
+ positionals: OPTIONAL_FILE_POSITIONAL,
745
+ constraints: EMPTY_CONSTRAINTS,
746
+ helpGroup: 'investigate',
747
+ handlerOwner: 'inline',
748
+ networkEffect: 'offline',
749
+ common: false,
750
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.registry-doctor']),
751
+ primaryArtefacts: Object.freeze([]),
752
+ planSupport: false,
753
+ additionalOutputFormats: Object.freeze([]),
754
+ bootstrapProfile: 'allowed',
755
+ }),
756
+ "registry-cohort": commandSeed({
757
+ reference: {
758
+ usage: 'whoisleuth registry-cohort [lookups-or-reports.json|jsonl] [--json] [--quiet] [--no-color]',
759
+ description: 'Build privacy-safe suffix and capability-profile timelines from saved observations or retained cohort reports.',
760
+ example: 'whoisleuth registry-cohort saved-lookups.jsonl --json',
761
+ boundary: 'This command is offline and omits domains, queries, and raw evidence. Input families cannot be mixed, and retained samples are never assumed independent.',
762
+ },
763
+ collection: { mode: 'offline', scope: 'Reads at most 500 saved Lookups or retained cohort reports from one unmixed family and emits bounded target-free timelines.' },
764
+ summary: 'Build target-free registry quality timelines',
765
+ options: ['--json', '--quiet', '--no-color'],
766
+ positionals: OPTIONAL_FILE_POSITIONAL,
767
+ constraints: EMPTY_CONSTRAINTS,
768
+ helpGroup: 'investigate',
769
+ handlerOwner: 'inline',
770
+ networkEffect: 'offline',
771
+ common: false,
772
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.registry-cohort']),
773
+ primaryArtefacts: Object.freeze([]),
774
+ planSupport: false,
775
+ additionalOutputFormats: Object.freeze([]),
776
+ bootstrapProfile: 'allowed',
777
+ }),
778
+ "registry-scaffold": commandSeed({
779
+ reference: {
780
+ usage: 'whoisleuth registry-scaffold --profile <id> --suffix <suffix> --scenario <registered|not_found|inconclusive>',
781
+ description: 'Create a bounded synthetic WHOIS fixture scaffold for one existing capability profile.',
782
+ example: 'whoisleuth registry-scaffold --profile example-profile --suffix test --scenario registered',
783
+ 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.',
784
+ },
785
+ collection: { mode: 'offline', scope: 'Reads the embedded registry capability catalogue and prints one synthetic fixture template.' },
786
+ summary: 'Create a sanitised registry fixture scaffold',
787
+ options: ['--profile', '--suffix', '--scenario'],
788
+ positionals: NO_POSITIONALS,
789
+ constraints: Object.freeze([
790
+ constraint({ kind: 'required', options: ['--profile', '--suffix', '--scenario'] }),
791
+ ]),
792
+ helpGroup: 'utilities',
793
+ handlerOwner: 'inline',
794
+ networkEffect: 'offline',
795
+ common: false,
796
+ schemaIdentifiers: Object.freeze([]),
797
+ primaryArtefacts: Object.freeze([]),
798
+ planSupport: false,
799
+ additionalOutputFormats: Object.freeze([]),
800
+ bootstrapProfile: 'command_owned',
801
+ }),
802
+ "risk-calibrate": commandSeed({
803
+ reference: {
804
+ usage: 'whoisleuth risk-calibrate [dataset.json] [--json|--summary-json] [--quiet] [--no-color]',
805
+ description: 'Replay reviewed labels against the current explainable Risk model.',
806
+ example: 'whoisleuth risk-calibrate calibration.json --summary-json',
807
+ 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.',
808
+ },
809
+ collection: { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
810
+ summary: 'Replay reviewed Risk labels offline',
811
+ options: ['--json', '--summary-json', '--quiet', '--no-color'],
812
+ positionals: OPTIONAL_FILE_POSITIONAL,
813
+ constraints: Object.freeze([
814
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--summary-json'] }),
815
+ ]),
816
+ helpGroup: 'assure',
817
+ handlerOwner: 'inline',
818
+ networkEffect: 'offline',
819
+ common: false,
820
+ schemaIdentifiers: Object.freeze([RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA]),
821
+ primaryArtefacts: Object.freeze([]),
822
+ planSupport: false,
823
+ additionalOutputFormats: Object.freeze([]),
824
+ bootstrapProfile: 'allowed',
825
+ }),
826
+ "lookalike-calibrate": commandSeed({
827
+ reference: {
828
+ usage: 'whoisleuth lookalike-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
829
+ description: 'Summarise reviewed candidate dispositions by mutation family without retaining domains.',
830
+ example: 'whoisleuth lookalike-calibrate reviewed-candidates.json --json',
831
+ boundary: 'Calibration is offline and diagnostic. It omits candidate identifiers, domains, notes, and evidence and never tunes generation or filtering automatically.',
832
+ },
833
+ collection: { mode: 'offline', scope: 'Reads at most 5,000 reviewed candidate labels from one dataset capped at 2 MiB.' },
834
+ summary: 'Summarise reviewed lookalike yield offline',
835
+ options: ['--json', '--quiet', '--no-color'],
836
+ positionals: OPTIONAL_FILE_POSITIONAL,
837
+ constraints: EMPTY_CONSTRAINTS,
838
+ helpGroup: 'assure',
839
+ handlerOwner: 'inline',
840
+ networkEffect: 'offline',
841
+ common: false,
842
+ schemaIdentifiers: Object.freeze(['whoisleuth.lookalike-calibration-input', 'whoisleuth.lookalike-calibration']),
843
+ primaryArtefacts: Object.freeze([]),
844
+ planSupport: false,
845
+ additionalOutputFormats: Object.freeze([]),
846
+ bootstrapProfile: 'allowed',
847
+ }),
848
+ "verify-artifact": commandSeed({
849
+ reference: {
850
+ usage: 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--manifest <manifest.json> --manifest-entry <artifact-N>] [--json] [--strict-exit] [--quiet] [--no-color]',
851
+ description: 'Validate a supported archive, claim passport, packet, manifest, saved Lookup, or supported Lookup-evidence export without printing evidence contents.',
852
+ example: 'whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit',
853
+ boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file; --strict-exit returns 4 when only an envelope or legacy projection integrity was verified.',
854
+ },
855
+ collection: { mode: 'offline', scope: 'Reads one selected bounded artefact and, when explicitly supplied, one manifest whose selected entry is compared by exact bytes and canonical identity.' },
856
+ summary: 'Validate saved evidence offline',
857
+ options: ['--passphrase-file', '--manifest', '--manifest-entry', '--json', '--strict-exit', '--quiet', '--no-color'],
858
+ positionals: OPTIONAL_FILE_POSITIONAL,
859
+ constraints: Object.freeze([
860
+ constraint({ kind: 'requires_all', option: '--manifest', requiredOptions: ['--manifest-entry'] }),
861
+ constraint({ kind: 'requires_all', option: '--manifest-entry', requiredOptions: ['--manifest'] }),
862
+ ]),
863
+ helpGroup: 'assure',
864
+ handlerOwner: 'inline',
865
+ networkEffect: 'offline',
866
+ common: true,
867
+ schemaIdentifiers: Object.freeze(['whoisleuth.offline-artifact-verification']),
868
+ primaryArtefacts: Object.freeze(['Offline verification report']),
869
+ planSupport: false,
870
+ additionalOutputFormats: Object.freeze([]),
871
+ bootstrapProfile: 'allowed',
872
+ }),
873
+ "interchange-report": commandSeed({
874
+ reference: {
875
+ usage: 'whoisleuth interchange-report [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
876
+ description: 'Report what one recognised portable artefact preserves, excludes, and supports across browser and CLI workflows.',
877
+ example: 'whoisleuth interchange-report workspace.json --json',
878
+ boundary: 'The report is offline and metadata-only. It does not echo targets, contacts, notes, passphrases, evidence values, or an unrecognised schema string.',
879
+ },
880
+ collection: { mode: 'offline', scope: 'Reads one selected bounded portable artefact and emits fixed compatibility metadata only.' },
881
+ summary: 'Report portable artefact fidelity offline',
882
+ options: ['--passphrase-file', '--json', '--quiet', '--no-color'],
883
+ positionals: OPTIONAL_FILE_POSITIONAL,
884
+ constraints: EMPTY_CONSTRAINTS,
885
+ helpGroup: 'assure',
886
+ handlerOwner: 'inline',
887
+ networkEffect: 'offline',
888
+ common: false,
889
+ schemaIdentifiers: Object.freeze(['whoisleuth.interchange-fidelity-report']),
890
+ primaryArtefacts: Object.freeze([]),
891
+ planSupport: false,
892
+ additionalOutputFormats: Object.freeze([]),
893
+ bootstrapProfile: 'allowed',
894
+ }),
895
+ "inspect-archive": commandSeed({
896
+ reference: {
897
+ usage: 'whoisleuth inspect-archive [archive.json] [--passphrase-file <file>] [--search <value>] [--require-match] [--reveal] [--expect-content-digest <sha256:digest>] [--json] [--quiet] [--no-color]',
898
+ description: `Summarise or search one current version-${WORKSPACE_ARCHIVE_VERSION} workspace archive, with exact ${LEGACY_WORKSPACE_ARCHIVE_DESCRIPTION} support and redacted output by default.`,
899
+ example: 'whoisleuth inspect-archive workspace.json --search example.test --json',
900
+ boundary: 'Exact matches require --reveal. Retired and future archive versions are rejected without changing data. The archive is read locally and is never uploaded.',
901
+ },
902
+ 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.` },
903
+ summary: 'Inspect an archive locally',
904
+ options: ['--passphrase-file', '--search', '--require-match', '--reveal', '--expect-content-digest', '--json', '--quiet', '--no-color'],
905
+ positionals: OPTIONAL_FILE_POSITIONAL,
906
+ constraints: Object.freeze([
907
+ constraint({ kind: 'requires_all', option: '--reveal', requiredOptions: ['--search'] }),
908
+ constraint({ kind: 'requires_all', option: '--require-match', requiredOptions: ['--search'] }),
909
+ ]),
910
+ helpGroup: 'assure',
911
+ handlerOwner: 'evidence',
912
+ networkEffect: 'offline',
913
+ common: false,
914
+ schemaIdentifiers: Object.freeze(['whoisleuth.workspace-archive-inspection']),
915
+ primaryArtefacts: Object.freeze([]),
916
+ planSupport: false,
917
+ additionalOutputFormats: Object.freeze([]),
918
+ bootstrapProfile: 'allowed',
919
+ }),
920
+ "sign-artifact": commandSeed({
921
+ reference: {
922
+ usage: 'whoisleuth sign-artifact [artifact.json] --private-key-file <file>',
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
+ helpGroup: 'assure',
935
+ handlerOwner: 'evidence',
936
+ networkEffect: 'offline',
937
+ common: false,
938
+ schemaIdentifiers: Object.freeze(['whoisleuth.signed-evidence-package']),
939
+ primaryArtefacts: Object.freeze([]),
940
+ planSupport: false,
941
+ additionalOutputFormats: Object.freeze([]),
942
+ bootstrapProfile: 'allowed',
943
+ }),
944
+ "verify-signature": commandSeed({
945
+ reference: {
946
+ usage: 'whoisleuth verify-signature [package.json] [--public-key-file <file>] [--json] [--quiet] [--no-color]',
947
+ description: 'Verify the cryptographic signature of one signed evidence package and report embedded-artefact assurance separately.',
948
+ example: 'whoisleuth verify-signature packet.signed.json --json',
949
+ boundary: 'A valid signature proves package consistency for the embedded key. It does not upgrade failed or unsupported embedded-artefact assurance or establish the holder\'s real-world identity or authority.',
950
+ },
951
+ collection: { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
952
+ summary: 'Verify a signed evidence package',
953
+ options: ['--public-key-file', '--json', '--quiet', '--no-color'],
954
+ positionals: OPTIONAL_FILE_POSITIONAL,
955
+ constraints: EMPTY_CONSTRAINTS,
956
+ helpGroup: 'assure',
957
+ handlerOwner: 'evidence',
958
+ networkEffect: 'offline',
959
+ common: false,
960
+ schemaIdentifiers: Object.freeze(['whoisleuth.evidence-signature-verification']),
961
+ primaryArtefacts: Object.freeze([]),
962
+ planSupport: false,
963
+ additionalOutputFormats: Object.freeze([]),
964
+ bootstrapProfile: 'allowed',
965
+ }),
966
+ "source-report": commandSeed({
967
+ reference: {
968
+ usage: 'whoisleuth source-report [lookup.json] [--json] [--quiet] [--no-color]',
969
+ description: 'Create a target-free reliability summary from a saved lookup.',
970
+ example: 'whoisleuth source-report lookup.json --json',
971
+ boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
972
+ },
973
+ collection: { mode: 'offline', scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
974
+ summary: 'Build a target-free source report',
975
+ options: ['--json', '--quiet', '--no-color'],
976
+ positionals: OPTIONAL_FILE_POSITIONAL,
977
+ constraints: EMPTY_CONSTRAINTS,
978
+ helpGroup: 'investigate',
979
+ handlerOwner: 'inline',
980
+ networkEffect: 'offline',
981
+ common: false,
982
+ schemaIdentifiers: Object.freeze(['whoisleuth.source-reliability-report']),
983
+ primaryArtefacts: Object.freeze([]),
984
+ planSupport: false,
985
+ additionalOutputFormats: Object.freeze([]),
986
+ bootstrapProfile: 'allowed',
987
+ }),
988
+ compare: commandSeed({
989
+ reference: {
990
+ usage: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
991
+ description: 'Compare separately attributed registry publications in a saved lookup.',
992
+ example: 'whoisleuth compare lookup.json --json',
993
+ boundary: 'Comparison is offline. Differences are review context and do not by themselves prove which publication is current.',
994
+ },
995
+ collection: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
996
+ summary: 'Compare registry publications in one lookup',
997
+ options: ['--json', '--quiet', '--no-color'],
998
+ positionals: OPTIONAL_FILE_POSITIONAL,
999
+ constraints: EMPTY_CONSTRAINTS,
1000
+ helpGroup: 'investigate',
1001
+ handlerOwner: 'inline',
1002
+ networkEffect: 'offline',
1003
+ common: false,
1004
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.compare']),
1005
+ primaryArtefacts: Object.freeze([]),
1006
+ planSupport: false,
1007
+ additionalOutputFormats: Object.freeze([]),
1008
+ bootstrapProfile: 'allowed',
1009
+ }),
1010
+ "page-compare": commandSeed({
1011
+ reference: {
1012
+ usage: 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
1013
+ description: 'Compare static page identity, favicon, technology, and TLS evidence in two saved deep lookups.',
1014
+ example: 'whoisleuth page-compare official.json candidate.json --json',
1015
+ boundary: 'Comparison is offline and component-based. It executes no page code and produces no aggregate similarity or maliciousness score.',
1016
+ },
1017
+ collection: { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
1018
+ summary: 'Compare saved static page evidence',
1019
+ options: ['--json', '--quiet', '--no-color'],
1020
+ positionals: Object.freeze([positional('sources', 'file', 2, 2)]),
1021
+ constraints: EMPTY_CONSTRAINTS,
1022
+ helpGroup: 'investigate',
1023
+ handlerOwner: 'inline',
1024
+ networkEffect: 'offline',
1025
+ common: false,
1026
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.page-compare']),
1027
+ primaryArtefacts: Object.freeze([]),
1028
+ planSupport: false,
1029
+ additionalOutputFormats: Object.freeze([]),
1030
+ bootstrapProfile: 'allowed',
1031
+ }),
1032
+ "mail-review": commandSeed({
1033
+ reference: {
1034
+ usage: 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
1035
+ description: 'Review passive MX, null MX, SPF, DMARC, and shared mail-provider evidence from saved Bulk results.',
1036
+ example: 'whoisleuth mail-review candidates.json --json',
1037
+ boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
1038
+ },
1039
+ collection: { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
1040
+ summary: 'Review saved passive mail evidence',
1041
+ options: ['--json', '--quiet', '--no-color'],
1042
+ positionals: OPTIONAL_FILE_POSITIONAL,
1043
+ constraints: EMPTY_CONSTRAINTS,
1044
+ helpGroup: 'investigate',
1045
+ handlerOwner: 'inline',
1046
+ networkEffect: 'offline',
1047
+ common: false,
1048
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.mail-review']),
1049
+ primaryArtefacts: Object.freeze([]),
1050
+ planSupport: false,
1051
+ additionalOutputFormats: Object.freeze([]),
1052
+ bootstrapProfile: 'allowed',
1053
+ }),
1054
+ "review-evidence": commandSeed({
1055
+ reference: {
1056
+ usage: 'whoisleuth review-evidence [evidence.json] [--mmdb <database-file>] [--json] [--strict-exit] [--quiet] [--no-color]',
1057
+ description: 'Review one versioned DNS, domain-change, routing, GeoIP, RDAP, or trust-store document offline.',
1058
+ example: 'whoisleuth review-evidence domain-change.json --json --strict-exit',
1059
+ boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
1060
+ },
1061
+ collection: { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
1062
+ summary: 'Review supplied evidence offline',
1063
+ options: ['--mmdb', '--json', '--strict-exit', '--quiet', '--no-color'],
1064
+ positionals: OPTIONAL_FILE_POSITIONAL,
1065
+ constraints: EMPTY_CONSTRAINTS,
1066
+ helpGroup: 'investigate',
1067
+ handlerOwner: 'inline',
1068
+ networkEffect: 'offline',
1069
+ common: true,
1070
+ schemaIdentifiers: Object.freeze([
1071
+ 'whoisleuth.cli.offline-evidence-review',
1072
+ 'whoisleuth.rdap-search-input',
1073
+ 'whoisleuth.dnssec-evidence-input',
1074
+ 'whoisleuth.tlsa-evidence-input',
1075
+ 'whoisleuth.rpki-route-input',
1076
+ 'whoisleuth.local-geoip-query',
1077
+ 'whoisleuth.encrypted-dns-plan-input',
1078
+ ]),
1079
+ primaryArtefacts: Object.freeze([]),
1080
+ planSupport: false,
1081
+ additionalOutputFormats: Object.freeze([]),
1082
+ bootstrapProfile: 'allowed',
1083
+ }),
1084
+ brief: commandSeed({
1085
+ reference: {
1086
+ usage: 'whoisleuth brief [lookup.json] [--json] [--quiet] [--no-color]',
1087
+ description: 'Turn one saved Lookup into a compact decision brief with facts, unknowns, contradictions, and next actions.',
1088
+ example: 'whoisleuth brief lookup.json --json',
1089
+ boundary: 'The command is offline, excludes raw upstream payloads, and does not create an analyst assertion or claim that the saved observation is current.',
1090
+ },
1091
+ collection: { mode: 'offline', scope: 'Reads one bounded saved Lookup and emits a compact source-attributed decision brief.' },
1092
+ summary: 'Build a decision brief from a saved lookup',
1093
+ options: ['--json', '--quiet', '--no-color'],
1094
+ positionals: OPTIONAL_FILE_POSITIONAL,
1095
+ constraints: EMPTY_CONSTRAINTS,
1096
+ helpGroup: 'investigate',
1097
+ handlerOwner: 'inline',
1098
+ networkEffect: 'offline',
1099
+ common: false,
1100
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-brief']),
1101
+ primaryArtefacts: Object.freeze([]),
1102
+ planSupport: false,
1103
+ additionalOutputFormats: Object.freeze([]),
1104
+ bootstrapProfile: 'allowed',
1105
+ }),
1106
+ "case-pack": commandSeed({
1107
+ reference: {
1108
+ usage: 'whoisleuth case-pack [cases.json] --audience <internal|trusted|public> --reviewed [--json] [--quiet] [--no-color]',
1109
+ description: `Package browser-created Case-schema-${CASE_SCHEMA_VERSION} records as a reviewed, audience-specific Case-pack v2.`,
1110
+ example: 'whoisleuth case-pack cases.json --audience trusted --reviewed --json',
1111
+ 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.',
1112
+ },
1113
+ collection: { mode: 'offline', scope: `Reads one bounded Case-schema-${CASE_SCHEMA_VERSION} browser export and writes a separate audience-specific Case-pack v2.` },
1114
+ summary: 'Build a reviewed case package',
1115
+ options: ['--audience', '--reviewed', '--json', '--quiet', '--no-color'],
1116
+ positionals: OPTIONAL_FILE_POSITIONAL,
1117
+ constraints: Object.freeze([
1118
+ constraint({ kind: 'required', options: ['--audience', '--reviewed'] }),
1119
+ ]),
1120
+ helpGroup: 'respond',
1121
+ handlerOwner: 'inline',
1122
+ networkEffect: 'offline',
1123
+ common: true,
1124
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.case-pack', 'whoisleuth.case-report']),
1125
+ primaryArtefacts: Object.freeze(['Reviewed Case-pack v2']),
1126
+ planSupport: false,
1127
+ additionalOutputFormats: Object.freeze([]),
1128
+ bootstrapProfile: 'allowed',
1129
+ }),
1130
+ "domain-control": commandSeed({
1131
+ reference: {
1132
+ usage: 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
1133
+ description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
1134
+ example: 'whoisleuth domain-control domain-control-input.json --json',
1135
+ boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
1136
+ },
1137
+ collection: { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
1138
+ summary: 'Build or review a domain control manifest',
1139
+ options: ['--json', '--quiet', '--no-color'],
1140
+ positionals: OPTIONAL_FILE_POSITIONAL,
1141
+ constraints: EMPTY_CONSTRAINTS,
1142
+ helpGroup: 'assure',
1143
+ handlerOwner: 'inline',
1144
+ networkEffect: 'offline',
1145
+ common: false,
1146
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.domain-control-review-input', 'whoisleuth.cli.domain-control-review']),
1147
+ primaryArtefacts: Object.freeze([]),
1148
+ planSupport: false,
1149
+ additionalOutputFormats: Object.freeze([]),
1150
+ bootstrapProfile: 'allowed',
1151
+ }),
1152
+ "monitor-once": commandSeed({
1153
+ reference: {
1154
+ usage: 'whoisleuth monitor-once [manifest.json] [--previous <snapshot.json>] [--limit <1-20>] [--concurrency <1-3>] [--fail-on <policies>] [--json|--junit] [--quiet] [--no-color]',
1155
+ description: 'Collect one bounded owned-domain review and compare it with an optional prior checkpoint.',
1156
+ example: 'whoisleuth monitor-once manifest.json --previous previous.json --json --output next.json',
1157
+ 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.',
1158
+ },
1159
+ collection: { mode: 'network', scope: 'Runs deep collection for at most 20 manifest domains with concurrency capped at 3.' },
1160
+ summary: 'Run one bounded domain control review',
1161
+ options: ['--previous', '--limit', '--concurrency', '--fail-on', '--json', '--junit', '--quiet', '--no-color'],
1162
+ positionals: OPTIONAL_FILE_POSITIONAL,
1163
+ constraints: Object.freeze([
1164
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit'] }),
1165
+ ]),
1166
+ helpGroup: 'assure',
1167
+ handlerOwner: 'inline',
1168
+ networkEffect: 'always_network',
1169
+ common: false,
1170
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.domain-control-monitor', 'whoisleuth.domain-control-flight-recorder.input']),
1171
+ primaryArtefacts: Object.freeze([]),
1172
+ planSupport: false,
1173
+ additionalOutputFormats: Object.freeze([]),
1174
+ bootstrapProfile: 'allowed',
1175
+ }),
1176
+ assurance: commandSeed({
1177
+ reference: {
1178
+ usage: 'whoisleuth assurance [assurance-input.json] [--json] [--quiet] [--no-color]',
1179
+ description: 'Review a versioned domain change, recovery-dependency, or retirement plan.',
1180
+ example: 'whoisleuth assurance domain-assurance.json --json',
1181
+ boundary: 'The command is offline and treats every provider label, readiness state, and evidence reference as analyst-authored input. It changes no configuration.',
1182
+ },
1183
+ collection: { mode: 'offline', scope: 'Reads one versioned plan capped at 2 MiB and makes no request or configuration change.' },
1184
+ summary: 'Review domain change, recovery, or retirement plans',
1185
+ options: ['--json', '--quiet', '--no-color'],
1186
+ positionals: OPTIONAL_FILE_POSITIONAL,
1187
+ constraints: EMPTY_CONSTRAINTS,
1188
+ helpGroup: 'assure',
1189
+ handlerOwner: 'inline',
1190
+ networkEffect: 'offline',
1191
+ common: false,
1192
+ schemaIdentifiers: Object.freeze(['whoisleuth.domain-assurance.input', 'whoisleuth.domain-assurance']),
1193
+ primaryArtefacts: Object.freeze([]),
1194
+ planSupport: false,
1195
+ additionalOutputFormats: Object.freeze([]),
1196
+ bootstrapProfile: 'allowed',
1197
+ }),
1198
+ "change-packet": commandSeed({
1199
+ reference: {
1200
+ usage: 'whoisleuth change-packet [change-packet-input.json] [--json] [--quiet] [--no-color]',
1201
+ description: 'Assemble pre-change, post-change, and planning evidence into one integrity-protected packet.',
1202
+ example: 'whoisleuth change-packet change-review.json --json',
1203
+ boundary: 'Assembly is offline. Readiness reflects only the supplied bounded evidence and does not authorise or perform a domain change.',
1204
+ },
1205
+ collection: { mode: 'offline', scope: 'Reads one versioned packet input capped at 6 MiB and makes no request or configuration change.' },
1206
+ summary: 'Build a reviewed change packet offline',
1207
+ options: ['--json', '--quiet', '--no-color'],
1208
+ positionals: OPTIONAL_FILE_POSITIONAL,
1209
+ constraints: EMPTY_CONSTRAINTS,
1210
+ helpGroup: 'respond',
1211
+ handlerOwner: 'inline',
1212
+ networkEffect: 'offline',
1213
+ common: false,
1214
+ schemaIdentifiers: Object.freeze(['whoisleuth.domain-change-packet.input', 'whoisleuth.domain-change-packet']),
1215
+ primaryArtefacts: Object.freeze([]),
1216
+ planSupport: false,
1217
+ additionalOutputFormats: Object.freeze([]),
1218
+ bootstrapProfile: 'allowed',
1219
+ }),
1220
+ "sharing-review": commandSeed({
1221
+ reference: {
1222
+ usage: 'whoisleuth sharing-review [artifact.json] --marking <level> --recipient-scope <scope> --purpose <text> [--human-reviewed] [--personal-data-reviewed] [--redactions-confirmed] [--json] [--quiet] [--no-color]',
1223
+ description: 'Lint one reviewed artefact against local integrity, marking, recipient, personal-data, and redaction controls.',
1224
+ example: 'whoisleuth sharing-review packet.json --marking amber --recipient-scope organization --purpose "Reviewed incident handoff" --human-reviewed --personal-data-reviewed --redactions-confirmed --json',
1225
+ 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.',
1226
+ },
1227
+ collection: { mode: 'offline', scope: 'Reads one artefact capped at 15 MiB, emits only bounded schema/version metadata and no content values, and performs no transmission.' },
1228
+ summary: 'Lint an artefact before deliberate sharing',
1229
+ options: ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
1230
+ positionals: OPTIONAL_FILE_POSITIONAL,
1231
+ constraints: Object.freeze([
1232
+ constraint({ kind: 'required', options: ['--marking', '--recipient-scope', '--purpose'] }),
1233
+ ]),
1234
+ helpGroup: 'respond',
1235
+ handlerOwner: 'inline',
1236
+ networkEffect: 'offline',
1237
+ common: false,
1238
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.sharing-review']),
1239
+ primaryArtefacts: Object.freeze([]),
1240
+ planSupport: false,
1241
+ additionalOutputFormats: Object.freeze([]),
1242
+ bootstrapProfile: 'allowed',
1243
+ }),
1244
+ "workflow-plan": commandSeed({
1245
+ reference: {
1246
+ usage: 'whoisleuth workflow-plan <recipe> <domain|brand> | --list | --explain <recipe> [--json] [--quiet] [--no-color]',
1247
+ description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
1248
+ example: 'whoisleuth workflow-plan domain-triage example.test --json',
1249
+ boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
1250
+ },
1251
+ collection: { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
1252
+ summary: 'Plan a fixed investigation recipe',
1253
+ options: ['--list', '--explain', '--json', '--quiet', '--no-color'],
1254
+ positionals: Object.freeze([
1255
+ positional('recipe', 'enum', 0, 1, INVESTIGATION_PLAN_RECIPES),
1256
+ positional('subject', 'text', 0, 1),
1257
+ ]),
1258
+ constraints: Object.freeze([
1259
+ constraint({ kind: 'mutually_exclusive', options: ['--list', '--explain'] }),
1260
+ ]),
1261
+ helpGroup: 'assure',
1262
+ handlerOwner: 'inline',
1263
+ networkEffect: 'offline',
1264
+ common: true,
1265
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.investigation-plan', 'whoisleuth.cli.workflow-recipe-catalogue']),
1266
+ primaryArtefacts: Object.freeze(['Plan-only workflow document']),
1267
+ planSupport: true,
1268
+ additionalOutputFormats: Object.freeze([]),
1269
+ bootstrapProfile: 'allowed',
1270
+ }),
1271
+ "workflow-run": commandSeed({
1272
+ reference: {
1273
+ usage: 'whoisleuth workflow-run <recipe> <domain|brand> [--select <step-id>=<path-or-value>]... [--approve-network] [--resume <state.json>] [--json] [--quiet] [--no-color]',
1274
+ description: 'Execute approved steps from a fixed investigation recipe and emit a resumable checkpoint.',
1275
+ example: 'whoisleuth workflow-run domain-triage example.test --resume run.json --select export=saved-lookup.json --json --output run-next.json',
1276
+ boundary: 'Only installed recipe commands can run. Network steps require explicit approval for each invocation. Repeat --select in placeholder order for one step; each bounded value replaces one exact placeholder and cannot start with a hyphen, become an option, or invoke a shell.',
1277
+ },
1278
+ collection: { mode: 'network', scope: 'Runs only fixed-recipe steps; network collection requires --approve-network and unresolved analyst selections pause.' },
1279
+ summary: 'Execute approved fixed-recipe steps',
1280
+ options: ['--select', '--approve-network', '--resume', '--json', '--quiet', '--no-color'],
1281
+ positionals: Object.freeze([
1282
+ positional('recipe', 'enum', 1, 1, RUNNABLE_INVESTIGATION_PLAN_RECIPES),
1283
+ positional('subject', 'text', 1, 1),
1284
+ ]),
1285
+ constraints: EMPTY_CONSTRAINTS,
1286
+ helpGroup: 'assure',
1287
+ handlerOwner: 'inline',
1288
+ networkEffect: 'conditional_network',
1289
+ common: false,
1290
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.investigation-run']),
1291
+ primaryArtefacts: Object.freeze(['Resumable workflow state']),
1292
+ planSupport: false,
1293
+ additionalOutputFormats: Object.freeze([]),
1294
+ bootstrapProfile: 'allowed',
1295
+ }),
1296
+ diff: commandSeed({
1297
+ reference: {
1298
+ usage: 'whoisleuth diff <left.json> <right.json> [--left-session <id> --right-session <id>] [--json] [--quiet] [--no-color]',
1299
+ description: 'Compare an earlier and later artefact from the same retained Lookup, Bulk-session, or domain-portfolio family.',
1300
+ example: 'whoisleuth diff earlier.json later.json --json',
1301
+ boundary: 'Comparison is offline: the left input is earlier and the right input is later. Inputs must belong to the same supported family. 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.',
1302
+ },
1303
+ collection: { mode: 'offline', scope: 'Reads two compatible retained artefacts capped at 8 MiB each and retains no source paths.' },
1304
+ summary: 'Compare two compatible retained artefacts',
1305
+ options: ['--left-session', '--right-session', '--json', '--quiet', '--no-color'],
1306
+ positionals: Object.freeze([positional('sources', 'file', 2, 2)]),
1307
+ constraints: EMPTY_CONSTRAINTS,
1308
+ helpGroup: 'assure',
1309
+ handlerOwner: 'inline',
1310
+ networkEffect: 'offline',
1311
+ common: true,
1312
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-diff']),
1313
+ primaryArtefacts: Object.freeze(['Retained-evidence comparison']),
1314
+ planSupport: false,
1315
+ additionalOutputFormats: Object.freeze([]),
1316
+ bootstrapProfile: 'allowed',
1317
+ }),
1318
+ reconcile: commandSeed({
1319
+ reference: {
1320
+ usage: 'whoisleuth reconcile <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
1321
+ description: 'Reconcile bounded values across independently labelled observations of one domain.',
1322
+ example: 'whoisleuth reconcile office.json mobile.json external.json --json',
1323
+ 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.',
1324
+ },
1325
+ collection: { mode: 'offline', scope: 'Reads 2 to 5 saved observations for one domain, capped at 32 MiB in total.' },
1326
+ summary: 'Reconcile independently labelled observations',
1327
+ options: ['--json', '--quiet', '--no-color'],
1328
+ positionals: Object.freeze([positional('sources', 'file', 2, 5)]),
1329
+ constraints: EMPTY_CONSTRAINTS,
1330
+ helpGroup: 'assure',
1331
+ handlerOwner: 'inline',
1332
+ networkEffect: 'offline',
1333
+ common: false,
1334
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-reconciliation']),
1335
+ primaryArtefacts: Object.freeze([]),
1336
+ planSupport: false,
1337
+ additionalOutputFormats: Object.freeze([]),
1338
+ bootstrapProfile: 'allowed',
1339
+ }),
1340
+ timeline: commandSeed({
1341
+ reference: {
1342
+ usage: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
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
+ helpGroup: 'assure',
1353
+ handlerOwner: 'inline',
1354
+ networkEffect: 'offline',
1355
+ common: false,
1356
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.lookup-timeline']),
1357
+ primaryArtefacts: Object.freeze(['Bounded retained-observation timeline']),
1358
+ planSupport: false,
1359
+ additionalOutputFormats: Object.freeze([]),
1360
+ bootstrapProfile: 'allowed',
1361
+ }),
1362
+ export: commandSeed({
1363
+ reference: {
1364
+ usage: 'whoisleuth export [lookup.json] [--markdown|--html|--compact] [--no-attribution]',
1365
+ description: 'Convert one saved lookup into a versioned evidence report.',
1366
+ example: 'whoisleuth export lookup.json --markdown',
1367
+ 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 schema ${PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION} 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.`,
1368
+ },
1369
+ collection: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
1370
+ summary: 'Convert a lookup to an evidence report',
1371
+ options: ['--markdown', '--html', '--compact', '--no-attribution'],
1372
+ positionals: OPTIONAL_FILE_POSITIONAL,
1373
+ constraints: Object.freeze([
1374
+ constraint({ kind: 'mutually_exclusive', options: ['--markdown', '--html'] }),
1375
+ constraint({ kind: 'excludes_all', option: '--compact', excludedOptions: ['--markdown', '--html'] }),
1376
+ constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
1377
+ ]),
1378
+ helpGroup: 'respond',
1379
+ handlerOwner: 'inline',
1380
+ networkEffect: 'offline',
1381
+ common: true,
1382
+ schemaIdentifiers: Object.freeze(['whoisleuth.lookup-evidence']),
1383
+ primaryArtefacts: Object.freeze(['Portable evidence report']),
1384
+ planSupport: false,
1385
+ additionalOutputFormats: Object.freeze(['JSON']),
1386
+ bootstrapProfile: 'allowed',
1387
+ }),
1059
1388
  });
1060
- function documentationMetadata(command, commandOptions, positionals) {
1061
- const effect = NETWORK_EFFECT_BY_COMMAND[command];
1389
+ const COMMAND_ORDER = Object.freeze(Object.keys(COMMAND_SEEDS));
1390
+ const HELP_GROUP_ORDER = Object.freeze(['investigate', 'respond', 'assure', 'utilities']);
1391
+ const HELP_COMMANDS_BY_GROUP = Object.freeze(Object.fromEntries(HELP_GROUP_ORDER.map((group) => [
1392
+ group,
1393
+ Object.freeze(COMMAND_ORDER.filter((command) => COMMAND_SEEDS[command].helpGroup === group)),
1394
+ ])));
1395
+ function documentationMetadata(seed, commonOptions, commandOptions, positionals) {
1062
1396
  const explicitAuthorisationRequired = commandOptions.some((option) => (option === '--owned-or-authorized' || option === '--active-probe' || option === '--approve-network'));
1063
1397
  const outputOptionFormats = [
1064
1398
  ['--json', 'JSON'], ['--jsonl', 'JSON Lines'], ['--junit', 'JUnit XML'],
@@ -1071,36 +1405,37 @@ function documentationMetadata(command, commandOptions, positionals) {
1071
1405
  if (commandOptions.includes(option))
1072
1406
  outputFormats.add(label);
1073
1407
  }
1074
- if (command === 'export')
1075
- outputFormats.add('JSON');
1408
+ for (const format of seed.additionalOutputFormats)
1409
+ outputFormats.add(format);
1076
1410
  const positionalLimits = positionals.map((item) => (`${item.name}: ${item.minimum}-${item.maximum} ${item.valueKind} value${item.maximum === 1 ? '' : 's'}`));
1077
1411
  return Object.freeze({
1078
- common: COMMON_COMMANDS.includes(command),
1079
- disclosureClass: effect === 'offline'
1412
+ common: seed.common,
1413
+ disclosureClass: seed.networkEffect === 'offline'
1080
1414
  ? 'none'
1081
1415
  : explicitAuthorisationRequired
1082
1416
  ? 'bounded_authorised_active'
1083
- : effect === 'conditional_network'
1417
+ : seed.networkEffect === 'conditional_network'
1084
1418
  ? 'conditional_bounded_passive'
1085
1419
  : 'bounded_passive',
1086
1420
  explicitAuthorisationRequired,
1087
- planSupport: commandOptions.includes('--plan') || command === 'workflow-plan',
1421
+ planSupport: seed.planSupport,
1088
1422
  failurePolicySupport: commandOptions.includes('--fail-on') || commandOptions.includes('--strict-exit'),
1089
- supportedSchemaIdentifiers: Object.freeze([...(SCHEMA_IDENTIFIERS_BY_COMMAND[command] ?? [])]),
1090
- inputLimits: Object.freeze([COMMAND_COLLECTION_SEED[command].scope, ...positionalLimits]),
1423
+ supportedSchemaIdentifiers: seed.schemaIdentifiers,
1424
+ inputLimits: Object.freeze([seed.collection.scope, ...positionalLimits]),
1091
1425
  outputLimits: Object.freeze([
1092
1426
  'Output is bounded by the command-owned formatter and document contract.',
1093
- ...(commonOptionsSeedForCommand(command).includes('--output')
1427
+ ...(commonOptions.includes('--output')
1094
1428
  ? ['Selected file output is atomic and replacement requires --force.']
1095
1429
  : []),
1096
1430
  ]),
1097
1431
  outputFormats: Object.freeze([...outputFormats]),
1098
- primaryEvidenceArtefacts: Object.freeze([...(PRIMARY_ARTEFACTS_BY_COMMAND[command] ?? [])]),
1432
+ primaryEvidenceArtefacts: seed.primaryArtefacts,
1099
1433
  });
1100
1434
  }
1101
1435
  const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) => {
1436
+ const seed = COMMAND_SEEDS[command];
1102
1437
  const commonOptions = Object.freeze([...commonOptionsSeedForCommand(command)]);
1103
- const commandOptions = Object.freeze([...OPTIONS_BY_COMMAND_SEED[command]]);
1438
+ const commandOptions = seed.options;
1104
1439
  const grammarOptions = Object.freeze([
1105
1440
  ...commonOptions.map((option) => optionSpec(command, option, 'common')),
1106
1441
  ...commandOptions.map((option) => optionSpec(command, option, 'command')),
@@ -1108,33 +1443,30 @@ const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) =>
1108
1443
  return Object.freeze({
1109
1444
  command,
1110
1445
  order,
1111
- reference: Object.freeze({
1112
- usage: COMMAND_USAGE_SEED[command],
1113
- ...COMMAND_DETAILS_SEED[command],
1114
- }),
1115
- collection: Object.freeze({ ...COMMAND_COLLECTION_SEED[command] }),
1446
+ reference: seed.reference,
1447
+ collection: seed.collection,
1116
1448
  completion: Object.freeze({
1117
- description: COMMAND_DESCRIPTIONS_SEED[command],
1449
+ description: seed.summary,
1118
1450
  commonOptions,
1119
1451
  options: commandOptions,
1120
1452
  }),
1121
1453
  grammar: Object.freeze({
1122
1454
  parserKey: command,
1123
- bootstrapProfile: command === 'registry-scaffold' ? 'command_owned' : 'allowed',
1455
+ bootstrapProfile: seed.bootstrapProfile,
1124
1456
  options: grammarOptions,
1125
- positionals: POSITIONALS_BY_COMMAND_SEED[command],
1126
- constraints: grammarConstraints(command),
1457
+ positionals: seed.positionals,
1458
+ constraints: grammarConstraints(commandOptions, seed.constraints),
1127
1459
  metaActions: Object.freeze(['help']),
1128
1460
  }),
1129
1461
  execution: Object.freeze({
1130
- handlerOwner: HANDLER_OWNER_BY_COMMAND[command],
1131
- networkEffect: NETWORK_EFFECT_BY_COMMAND[command],
1462
+ handlerOwner: seed.handlerOwner,
1463
+ networkEffect: seed.networkEffect,
1132
1464
  }),
1133
1465
  help: Object.freeze({
1134
- group: HELP_GROUP_BY_COMMAND[command],
1135
- summary: COMMAND_DESCRIPTIONS_SEED[command],
1466
+ group: seed.helpGroup,
1467
+ summary: seed.summary,
1136
1468
  }),
1137
- documentation: documentationMetadata(command, commandOptions, POSITIONALS_BY_COMMAND_SEED[command]),
1469
+ documentation: documentationMetadata(seed, commonOptions, commandOptions, seed.positionals),
1138
1470
  });
1139
1471
  }));
1140
1472
  const CLI_COMMANDS = Object.freeze(CLI_COMMAND_REGISTRY.map((definition) => definition.command));