@slicedearth/whoisleuth-cli 2.6.0 → 2.8.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 (96) hide show
  1. package/DISCLOSURE +8 -5
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -857
  5. package/cli/artifact-validation/lookup-evidence.mjs +15 -2
  6. package/cli/artifact-verify.mjs +32 -1
  7. package/cli/bulk-command-runner.mjs +5 -4
  8. package/cli/case-command.mjs +17 -17
  9. package/cli/case-pack.mjs +7 -1
  10. package/cli/ci-report.mjs +17 -5
  11. package/cli/collection-arguments.mjs +6 -0
  12. package/cli/collection-command-definitions.mjs +13 -3
  13. package/cli/command-definition.mjs +7 -5
  14. package/cli/discovery-scan-command-runner.mjs +13 -7
  15. package/cli/doctor.mjs +37 -3
  16. package/cli/domain-control-monitor.mjs +25 -2
  17. package/cli/domain-control-observations.mjs +52 -15
  18. package/cli/fail-policy.mjs +31 -22
  19. package/cli/formatters/html.mjs +3 -0
  20. package/cli/formatters/json.mjs +8 -1
  21. package/cli/formatters/terminal-lookup.mjs +24 -1
  22. package/cli/history-command-definitions.mjs +1 -1
  23. package/cli/investigation-plan.mjs +15 -0
  24. package/cli/lookup-brief.mjs +11 -5
  25. package/cli/lookup-command-runner.mjs +26 -18
  26. package/cli/lookup-diff.mjs +1 -0
  27. package/cli/review-command-definitions.mjs +11 -11
  28. package/cli/risk-policy.mjs +83 -0
  29. package/cli/saved-lookup.mjs +22 -7
  30. package/cli/source-reliability.mjs +12 -10
  31. package/cli/support-arguments.mjs +1 -1
  32. package/cli/support-command-definitions.mjs +5 -4
  33. package/cli/support-command-runner.mjs +6 -4
  34. package/cli/workflow-command-definitions.mjs +2 -1
  35. package/cli/workflow-command-runner.mjs +5 -1
  36. package/docs/cli-reference.md +56 -11
  37. package/docs/cli.md +162 -33
  38. package/docs/contextual-reviews.md +10 -0
  39. package/docs/infrastructure-observations.md +9 -0
  40. package/lib/dmarc-discovery.mjs +4 -2
  41. package/lib/dns-inheritance-review.mjs +14 -1
  42. package/lib/evidence-export.mjs +10 -4
  43. package/lib/evidence-report.mjs +2 -1
  44. package/lib/generated/cisa-kev-catalog.mjs +19 -4
  45. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  46. package/lib/generated/retire-browser-catalog.mjs +178 -5
  47. package/lib/idn-confusables.mjs +5 -1
  48. package/lib/lookup-collection-receipt.mjs +52 -0
  49. package/lib/lookup-diagnostics.mjs +2 -12
  50. package/lib/lookup-progress.mjs +29 -31
  51. package/lib/lookup-response-contract.mjs +9 -0
  52. package/lib/lookup-response.mjs +15 -14
  53. package/lib/lookup-source-progress.mjs +4 -2
  54. package/lib/lookup.mjs +33 -2
  55. package/lib/rdap-capabilities.mjs +43 -9
  56. package/lib/registry-insights.mjs +17 -6
  57. package/lib/sslbl-certificates.generated.mjs +143 -142
  58. package/lib/whois-authority.mjs +56 -1
  59. package/lib/whois-chain.mjs +2 -1
  60. package/lib/whois-contracts.mjs +2 -0
  61. package/lib/whois-normalization.mjs +4 -4
  62. package/package.json +6 -6
  63. package/packages/analysis/bounded-json.mjs +12 -2
  64. package/packages/analysis/brand-profile-signals.mjs +42 -0
  65. package/packages/cases/case-attachment-model.mjs +36 -1
  66. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  67. package/packages/cases/case-evidence-links.mjs +28 -0
  68. package/packages/cases/case-evidence-model.mjs +39 -6
  69. package/packages/cases/case-migration-model.mjs +9 -1
  70. package/packages/cases/case-object-coverage.mjs +106 -20
  71. package/packages/cases/case-record-operations.mjs +13 -0
  72. package/packages/cases/case-record-projection.mjs +28 -2
  73. package/packages/cases/case-report.mjs +5 -4
  74. package/packages/cases/case-response-packet-validation.mjs +880 -0
  75. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  76. package/packages/cases/case-response-packet.mjs +2 -15
  77. package/packages/cases/case-response-review-inputs.mjs +18 -1
  78. package/packages/cases/case-type-presentation.mjs +10 -0
  79. package/packages/cases/case-workflow-metadata.mjs +0 -8
  80. package/packages/comparison/capture-context.mjs +3 -2
  81. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  82. package/packages/contracts/case-portability.mjs +59 -28
  83. package/packages/contracts/cli-doctor.mjs +4 -0
  84. package/packages/contracts/cli-lookup.mjs +57 -46
  85. package/packages/contracts/lookup-evidence.mjs +12 -2
  86. package/packages/contracts/registry-insights.mjs +6 -0
  87. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  88. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  89. package/packages/evidence/lookup-collection-review.mjs +74 -0
  90. package/packages/interchange/external-findings-import.mjs +8 -4
  91. package/packages/investigation/bulk-domain-comparison.mjs +17 -8
  92. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  93. package/packages/investigation/domain-history-review.mjs +7 -1
  94. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  95. package/packages/workspace/bulk-session-model.mjs +8 -0
  96. package/third-party-notices.txt +30 -30
@@ -9,7 +9,7 @@ import { CLI_CASE_OPERATIONS, CLI_INDICATOR_OPERATIONS, positional, OPTIONAL_FIL
9
9
  export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
10
10
  "verify-artifact": commandSeed({
11
11
  reference: {
12
- description: 'Validate a supported archive, ordinary Case export, claim passport, packet, manifest, saved Lookup or Lookup-evidence export without printing evidence contents. Use --package for an evidence ZIP or encrypted package, with --passphrase-file to unlock it; use --folder ./evidence for an unencrypted evidence folder. Add --bagit with --package or --folder to verify BagIt 1.0.',
12
+ description: 'Validate a supported archive, ordinary case export, claim passport, packet, manifest, saved Lookup or Lookup-evidence export without printing evidence contents. Use --package for an evidence ZIP or encrypted package, with --passphrase-file to unlock it; use --folder ./evidence for an unencrypted evidence folder. Add --bagit with --package or --folder to verify BagIt 1.0.',
13
13
  example: 'whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit',
14
14
  boundary: 'Verification is offline and redacted. ZIP and folder entries are reported separately without importing them. Case exports are checked without repairing content; ordinary package review also counts original references with matching bytes. Ordinary folders allow only the declared layout; BagIt allows bounded nested payloads and checks SHA-256/SHA-512 manifests without interpreting payloads. Symbolic links are refused. BagIt fetch.txt is never fetched; missing files, mismatches and unsupported algorithms remain explicit. Package digests describe bytes, not filesystem metadata or authenticity. In scripts, use --strict-exit: incomplete verification returns 4. Unencrypted workspace archives always return 4 under --strict-exit because section digests do not cover mutable root metadata; structure-only formats can pass their narrower contract. Default exit 0 means the report was produced, not that its checks passed.',
15
15
  },
@@ -56,9 +56,9 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
56
56
  }),
57
57
  "source-report": commandSeed({
58
58
  reference: {
59
- description: 'Create a target-free reliability summary from a saved lookup.',
59
+ description: 'Summarise source reliability from saved Lookup or Bulk evidence, or merge source-reliability reports.',
60
60
  example: 'whoisleuth source-report lookup.json --json',
61
- boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
61
+ boundary: 'Accepts supported Lookup, Bulk or Bulk-item documents, or an unmixed set of source-reliability reports. Retains source states and timings but excludes targets, queries, endpoints and raw evidence.',
62
62
  },
63
63
  collection: { scope: 'Reads bounded saved evidence and emits target-free source reliability data.' },
64
64
  summary: 'Build a target-free source report',
@@ -160,7 +160,7 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
160
160
  example: 'whoisleuth intake email message.eml --json',
161
161
  boundary: 'Offline only: no destination, embedded command, phone number or QR payload is opened, contacted or executed. Output omits original bodies, subjects, address local parts, URL paths, queries and fragments. Literal IPs and labelled hashes are bounded text observations, not lookup targets or verdicts; headers, URL contents, QR, HAR and identity-event fields are not scanned for them. Plain-text phone discovery writes only counts, source digest, ranges and status to stderr; --quiet suppresses it. Optional --intake-context reads one separate file up to 8 KiB, not stdin. Version 1 carries distribution declarations; version 2 can also bind explicit phone selections and a supplied destination pair to the exact input digest. Selected phone strings and source declarations enter output; unselected candidates and exact private URLs do not. Authentication headers remain attributed claims. Selected PDF and DOCX review retains part identities and extraction coverage; embedded raster QR support does not imply complete rendered-page review. HAR retains minimised request order and timings, never replay authority, headers, cookies or bodies. Use --strict-exit to return 4 for partial coverage.',
162
162
  },
163
- collection: { scope: 'Reads one selected text, MIME email, calendar, PNG, PDF, DOCX, HAR or identity-event JSON file and, only with --intake-context, one bounded declaration/selection file. No collection or automatic Case write.' },
163
+ collection: { scope: 'Reads one selected text, MIME email, calendar, PNG, PDF, DOCX, HAR or identity-event JSON file and, only with --intake-context, one bounded declaration/selection file. No collection or automatic case write.' },
164
164
  summary: 'Review selected files and identity events offline',
165
165
  options: ['--json', '--reported-action', '--trusted-auth-header', '--intake-context', '--strict-exit', '--quiet', '--no-color'],
166
166
  positionals: Object.freeze([positional('kind', 'enum', 1, 1, MESSAGE_INTAKE_KINDS), positional('source', 'file', 0, 1, [], 'argv_or_stdin')]),
@@ -226,12 +226,12 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
226
226
  }),
227
227
  case: commandSeed({
228
228
  reference: {
229
- description: 'Show or open a local Case, append a note or evidence pin, link evidence, record an assessment or offline recheck, retain an incident-link, create an action, append an action-event, save a recheck-question, or close-object. JSON mutations use --input; notes use --text or --note-file. Link input contains fromPinId, toPinId, kind and basis. Action-event input contains id and transition. Explicit responseObjects bind an action; a typed objectOutcome requires an explicit affected subset on its event. Object closure never closes other objects or the Case. Mutations require --output and write the complete current Case export.',
229
+ description: 'Show or open a local case, append a note or evidence pin, link evidence, record an assessment or offline recheck, retain an incident-link, create an action, append an action-event, save a recheck-question, or close-object. JSON mutations use --input; notes use --text or --note-file. Link input contains fromPinId, toPinId, kind and basis. Action-event input contains id and transition. Explicit responseObjects bind an action; a typed objectOutcome requires an explicit affected subset on its event. Object closure never closes other objects or the case. Mutations require --output and write the complete current case export.',
230
230
  example: 'whoisleuth case open --domain example.test --output cases.json\n whoisleuth case show cases.json\n whoisleuth case note cases.json --text "Review the retained observation" --output cases.json --force',
231
- boundary: 'No database, browser launch, request or external report is created. Select --case-id when a file contains multiple Cases. Existing files require --force; --expect-file-digest sha256:<digest> additionally checks the exact file reviewed earlier. Source and output leases reject concurrent changes. Interrupted .workflow.lock files require deliberate inspection. Recheck records supplied observations; it does not collect them. Not reproduced requires an existing saved question, a complete observation and comparable conditions. Working exports include private analyst content and file references, not attached file bytes.',
231
+ boundary: 'No database, browser launch, request or external report is created. Select --case-id when a file contains multiple cases. Existing files require --force; --expect-file-digest sha256:<digest> additionally checks the exact file reviewed earlier. Source and output leases reject concurrent changes. Interrupted .workflow.lock files require deliberate inspection. Recheck records supplied observations; it does not collect them. Not reproduced requires an existing saved question, a complete observation and comparable conditions. Working exports include private analyst content and file references, not attached file bytes.',
232
232
  },
233
- collection: { scope: `Reads exact Case schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}. Input is bounded to ${MAX_EDITABLE_CASE_INPUT_BYTES / 1024 / 1024} MiB including formatting; the complete canonical Case store must fit ${MAX_CASE_STORE_BYTES / 1024 / 1024} MiB without pruning. Writes current schema ${CASE_SCHEMA_VERSION}.` },
234
- summary: 'Review and update ordinary local Case files',
233
+ collection: { scope: `Reads exact case schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}. Input is bounded to ${MAX_EDITABLE_CASE_INPUT_BYTES / 1024 / 1024} MiB including formatting; the complete canonical case store must fit ${MAX_CASE_STORE_BYTES / 1024 / 1024} MiB without pruning. Writes current schema ${CASE_SCHEMA_VERSION}.` },
234
+ summary: 'Review and update ordinary local case files',
235
235
  options: ['--case-id', '--domain', '--title', '--new-incident', '--text', '--note-file', '--input', '--expect-file-digest', '--json', '--no-color'],
236
236
  positionals: Object.freeze([positional('operation', 'enum', 1, 1, CLI_CASE_OPERATIONS), positional('source', 'file', 0, 1)]),
237
237
  constraints: Object.freeze([constraint({ kind: 'mutually_exclusive', options: ['--text', '--note-file'] })]),
@@ -256,11 +256,11 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
256
256
  }),
257
257
  "case-pack": commandSeed({
258
258
  reference: {
259
- description: `Package browser-created Case records from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} as a reviewed, audience-specific Case-pack v2 with current schema ${CASE_SCHEMA_VERSION}.`,
259
+ description: `Package browser-created case records from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} as a reviewed, audience-specific Case-pack v2 with current schema ${CASE_SCHEMA_VERSION}.`,
260
260
  example: 'whoisleuth case-pack cases.json --audience trusted --reviewed --json',
261
- boundary: 'The command creates a separate offline package without changing the source or durable Cases and requires an explicit review acknowledgement. Every audience, including public, retains domain evidence, Case, pin and decision identifiers, tags, decision summaries and rationale. Review that analyst-authored content as well as the exclusions before sharing.',
261
+ boundary: 'The command creates a separate offline package without changing the source or durable cases and requires an explicit review acknowledgement. Every audience, including public, retains domain evidence, case, pin and decision identifiers, tags, decision summaries and rationale. Review that analyst-authored content as well as the exclusions before sharing.',
262
262
  },
263
- collection: { scope: `Reads one bounded Case export from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} and writes a separate audience-specific Case-pack v2.` },
263
+ collection: { scope: `Reads one bounded case export from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} and writes a separate audience-specific Case-pack v2.` },
264
264
  summary: 'Build a reviewed case package',
265
265
  options: ['--audience', '--reviewed', '--json', '--quiet', '--no-color'],
266
266
  positionals: OPTIONAL_FILE_POSITIONAL,
@@ -0,0 +1,83 @@
1
+ import { analyzeDomainIdn } from "../lib/idn-confusables.mjs";
2
+ import { profileSignals } from "../packages/analysis/brand-profile-signals.mjs";
3
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../packages/analysis/bounded-json.mjs";
4
+ import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
5
+ import { explainRiskScore } from "../packages/analysis/risk-scoring.mjs";
6
+ import { BRAND_PROFILE_SCHEMA, MAX_PROFILES, MAX_PROFILE_IMPORT_BYTES } from "../packages/contracts/workspace-portability.mjs";
7
+ import { CliUsageError } from "./errors.mjs";
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
+ }
11
+ /** Read one explicitly selected export, not browser storage or configuration. */
12
+ export async function readCliRiskProfile(source, readInput, selectedId) {
13
+ if (!source)
14
+ return null;
15
+ const text = await readInput(source, MAX_PROFILE_IMPORT_BYTES, 'Brand Profile export');
16
+ try {
17
+ const raw = parseBoundedJsonObject(text, {
18
+ maximumBytes: MAX_PROFILE_IMPORT_BYTES, limits: boundedJsonLimitsForBytes(MAX_PROFILE_IMPORT_BYTES), label: 'Brand Profile export',
19
+ });
20
+ if (raw.schema !== BRAND_PROFILE_SCHEMA || typeof raw.version !== 'number'
21
+ || !Array.isArray(raw.profiles) || !raw.profiles.length || raw.profiles.length > MAX_PROFILES)
22
+ throw new TypeError();
23
+ const { normalizeBrandProfileId, normalizeBrandProfileStore } = await import("../packages/workspace/brand-profile-model.mjs");
24
+ if (selectedId !== undefined && normalizeBrandProfileId(selectedId) !== selectedId)
25
+ throw new TypeError();
26
+ const selected = selectedId === undefined ? raw.profiles : raw.profiles.filter(value => record(value).id === selectedId);
27
+ if (selected.length !== 1)
28
+ throw new TypeError();
29
+ const profile = normalizeBrandProfileStore({ ...raw, profiles: selected }).profiles[0];
30
+ if (!profile || !profile.officialDomains.length)
31
+ throw new TypeError();
32
+ // Private notes, contacts and other profile fields never enter output or
33
+ // checkpoints. Keep only the existing comparison inputs for this run.
34
+ return {
35
+ officialDomains: profile.officialDomains,
36
+ approvedPartnerDomains: profile.approvedPartnerDomains,
37
+ allowlistedDomains: profile.allowlistedDomains,
38
+ officialFaviconHash: profile.officialFaviconHash,
39
+ officialFaviconPHash: profile.officialFaviconPHash,
40
+ };
41
+ }
42
+ catch {
43
+ throw new CliUsageError('Use a supported Brand Profile export with a valid official domain; select one unambiguous profile with --brand-id if the export contains multiple profiles.');
44
+ }
45
+ }
46
+ /** Adapt collected evidence, never an asserted score, lane or severity label. */
47
+ export function explainCliLookupRisk(document, profile, mode = document.mode) {
48
+ if (document.type !== undefined && document.type !== 'domain')
49
+ return null;
50
+ const availability = record(document.availability);
51
+ const domain = canonicalRegistrableDomain(document.registrableDomain ?? document.domain ?? availability.domain);
52
+ if (!domain || canonicalRegistrableDomain(availability.domain) !== domain)
53
+ return null;
54
+ const diagnostics = record(document.diagnostics);
55
+ const sourceCoverage = [
56
+ ...['rdap', 'whois', 'availability'].map(source => ({ source, state: record(diagnostics[source]).status })),
57
+ ...['dns', 'http', 'tls', 'pageIdentity'].map(source => ({
58
+ source: source === 'pageIdentity' ? 'page-identity' : source,
59
+ state: record(availability[source]).status,
60
+ })),
61
+ ].map(item => ({ ...item, state: item.state === 'disabled' ? 'skipped' : item.state }));
62
+ return explainRiskScore({
63
+ domain,
64
+ availability: availability.state,
65
+ ...profileSignals(domain, availability, profile),
66
+ hasActiveBrandProfile: Boolean(profile),
67
+ idnReferenceMatch: profile ? Boolean(analyzeDomainIdn(domain, profile.officialDomains)?.referenceMatches.length) : null,
68
+ mutationTypes: document.mutationTypes,
69
+ hasPasswordField: availability.hasPasswordField,
70
+ hasExternalFormAction: availability.hasExternalFormAction,
71
+ hasExternalPasswordForm: availability.hasExternalPasswordForm,
72
+ phishingLanguageMatch: availability.phishingLanguageMatch,
73
+ activityStatus: availability.activityStatus,
74
+ hasMx: availability.hasMx,
75
+ hasSpf: availability.hasSpf,
76
+ hasDmarc: availability.hasDmarc,
77
+ domainAgeDays: availability.domainAgeDays,
78
+ privacyProtected: availability.privacyProtected,
79
+ scanDepth: mode,
80
+ observedAt: document.observedAt ?? record(availability.http).observedAt,
81
+ sourceCoverage,
82
+ });
83
+ }
@@ -7,7 +7,8 @@ import { CliUsageError } from "./errors.mjs";
7
7
  import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../lib/homepage-metadata-contract.mjs";
8
8
  import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../lib/registrar-standing-contract.mjs";
9
9
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
10
- import { CLI_LOOKUP_SCHEMA as SAVED_LOOKUP_SCHEMA, CLI_LOOKUP_VERSION as SAVED_LOOKUP_SCHEMA_VERSION, PUBLIC_CLI_LOOKUP_VERSION as PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION, MAX_CLI_LOOKUP_BYTES as MAX_SAVED_LOOKUP_INPUT_BYTES, MAX_CLI_LOOKUP_JSON_CONTAINER_ITEMS as MAX_BOUNDED_JSON_CONTAINER_ITEMS, MAX_CLI_LOOKUP_JSON_DEPTH as MAX_BOUNDED_JSON_DEPTH, MAX_CLI_LOOKUP_JSON_KEYS as MAX_BOUNDED_JSON_KEYS, MAX_CLI_LOOKUP_JSON_VALUES as MAX_BOUNDED_JSON_VALUES, MAX_CLI_LOOKUP_STRING_LENGTH as MAX_SAVED_LOOKUP_STRING_LENGTH, SUPPORTED_CLI_LOOKUP_VERSIONS as SUPPORTED_SAVED_LOOKUP_SCHEMA_VERSIONS, } from "../packages/contracts/cli-lookup.mjs";
10
+ import { parseLookupCollectionReceipt } from "../packages/evidence/lookup-collection-receipt.mjs";
11
+ import { CLI_LOOKUP_SCHEMA as SAVED_LOOKUP_SCHEMA, CLI_LOOKUP_VERSION as SAVED_LOOKUP_SCHEMA_VERSION, PUBLIC_CLI_LOOKUP_VERSION as PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION, HOMEPAGE_CLI_LOOKUP_VERSION, COLLECTION_RECEIPT_CLI_LOOKUP_VERSION, MAX_CLI_LOOKUP_BYTES as MAX_SAVED_LOOKUP_INPUT_BYTES, MAX_CLI_LOOKUP_JSON_CONTAINER_ITEMS as MAX_BOUNDED_JSON_CONTAINER_ITEMS, MAX_CLI_LOOKUP_JSON_DEPTH as MAX_BOUNDED_JSON_DEPTH, MAX_CLI_LOOKUP_JSON_KEYS as MAX_BOUNDED_JSON_KEYS, MAX_CLI_LOOKUP_JSON_VALUES as MAX_BOUNDED_JSON_VALUES, MAX_CLI_LOOKUP_STRING_LENGTH as MAX_SAVED_LOOKUP_STRING_LENGTH, SUPPORTED_CLI_LOOKUP_VERSIONS as SUPPORTED_SAVED_LOOKUP_SCHEMA_VERSIONS, } from "../packages/contracts/cli-lookup.mjs";
11
12
  const RDAP_STATUSES = new Set([
12
13
  'success', 'complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
13
14
  'unavailable', 'rate_limited', 'not_applicable', 'stale',
@@ -317,23 +318,31 @@ function assertLookupEnvelope(document, label) {
317
318
  const publicationMetadata = pageIdentity?.publicationMetadata;
318
319
  const deliveryMetadata = httpResponse?.deliveryMetadata;
319
320
  const registrarStanding = document.registrarStanding;
321
+ if (document.collectionReceipt !== undefined) {
322
+ if (Number(document.version) < COLLECTION_RECEIPT_CLI_LOOKUP_VERSION)
323
+ throw new TypeError(`${label} collection receipt requires saved Lookup version ${COLLECTION_RECEIPT_CLI_LOOKUP_VERSION}.`);
324
+ const receipt = parseLookupCollectionReceipt(document.collectionReceipt);
325
+ if (receipt.targetClass !== document.type || receipt.requestedDepth !== document.mode) {
326
+ throw new TypeError(`${label} collection receipt does not match the Lookup.`);
327
+ }
328
+ }
320
329
  if (document.version === PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION
321
330
  && (publicationMetadata !== undefined || deliveryMetadata !== undefined || registrarStanding !== undefined)) {
322
- throw new TypeError(`${label} version ${PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION} cannot contain version ${SAVED_LOOKUP_SCHEMA_VERSION} evidence fields.`);
331
+ throw new TypeError(`${label} version ${PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION} cannot contain version ${HOMEPAGE_CLI_LOOKUP_VERSION} evidence fields.`);
323
332
  }
324
- if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
333
+ if (document.version >= HOMEPAGE_CLI_LOOKUP_VERSION
325
334
  && (publicationMetadata !== undefined && (!['success', 'partial'].includes(String(pageIdentity?.status))
326
335
  || !validPagePublicationMetadata(publicationMetadata))
327
336
  || deliveryMetadata !== undefined && (!['success', 'partial'].includes(String(http?.status))
328
337
  || !validHttpDeliveryMetadata(deliveryMetadata)))) {
329
338
  throw new TypeError(`${label} contains invalid homepage metadata.`);
330
339
  }
331
- if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
340
+ if (document.version >= HOMEPAGE_CLI_LOOKUP_VERSION
332
341
  && registrarStanding !== undefined
333
342
  && !validRegistrarStanding(registrarStanding)) {
334
343
  throw new TypeError(`${label} contains invalid registrar standing.`);
335
344
  }
336
- if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
345
+ if (document.version >= HOMEPAGE_CLI_LOOKUP_VERSION
337
346
  && registrarStanding !== undefined
338
347
  && !registrarStandingObservedBy(registrarStanding, generatedAt)) {
339
348
  throw new TypeError(`${label} registrar standing was observed after the saved Lookup time.`);
@@ -346,7 +355,7 @@ function assertLookupEnvelope(document, label) {
346
355
  if ((whoisStatus === 'complete' || whoisStatus === 'partial') && !whoisParsed) {
347
356
  throw new TypeError('Successful WHOIS input is missing normalised parsed data.');
348
357
  }
349
- if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
358
+ if (document.version >= HOMEPAGE_CLI_LOOKUP_VERSION
350
359
  && validRegistrarStanding(registrarStanding)
351
360
  && registrarStanding.ianaId !== resolveRegistrarIanaId(rdapParsed, whoisParsed)) {
352
361
  throw new TypeError(`${label} registrar standing does not match its retained registration sources.`);
@@ -430,7 +439,7 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
430
439
  throw new TypeError('Lookup document generation time must be valid and include an explicit timezone.');
431
440
  }
432
441
  const classifiedSnapshot = snapshotKnownDataFields(classified, 'Lookup classification', new Set(['type', 'value', 'inputHostname', 'registrableDomain', 'isSubdomain']));
433
- const contextSnapshot = snapshotKnownDataFields(collectionContext, 'Lookup collection context', new Set(['observerLabel', 'vantageLabel']));
442
+ const contextSnapshot = snapshotKnownDataFields(collectionContext, 'Lookup collection context', new Set(['observerLabel', 'vantageLabel', 'collectionReceipt']));
434
443
  const observerLabel = typeof contextSnapshot.observerLabel === 'string'
435
444
  ? contextSnapshot.observerLabel
436
445
  : null;
@@ -439,6 +448,12 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
439
448
  : null;
440
449
  const resultSnapshot = snapshotLookupResultRoot(result);
441
450
  delete resultSnapshot.collectionContext;
451
+ if (Object.hasOwn(contextSnapshot, 'collectionReceipt')) {
452
+ delete resultSnapshot.collectionReceipt;
453
+ const receipt = parseLookupCollectionReceipt(contextSnapshot.collectionReceipt);
454
+ if (receipt)
455
+ resultSnapshot.collectionReceipt = receipt;
456
+ }
442
457
  const classifiedType = classifiedSnapshot.type;
443
458
  if (typeof classifiedType !== 'string' || !QUERY_TYPES.has(classifiedType)) {
444
459
  throw new TypeError('Lookup classification has an unsupported query type.');
@@ -730,6 +730,8 @@ export function buildSourceReliabilityReport(raw, generatedAt = new Date().toISO
730
730
  });
731
731
  }
732
732
  export function formatSourceReliabilityReport(report) {
733
+ const duration = (value) => value
734
+ ? `median ${value.median} ms / p95 ${value.p95} ms (range ${value.minimum}-${value.maximum} ms)` : 'unmeasured';
733
735
  const lines = [
734
736
  'WHOISleuth source reliability report',
735
737
  `Documents: ${report.documentsReviewed}`,
@@ -741,16 +743,16 @@ export function formatSourceReliabilityReport(report) {
741
743
  `Exceptions: ${report.totals.truncations} truncated · ${report.totals.rateLimits} rate-limited`,
742
744
  ];
743
745
  for (const source of report.sources) {
744
- const states = Object.entries(source.states).map(([state, count]) => `${state} ${count}`).join(', ') || 'none';
745
- const timing = source.durationMs.lookupTiming;
746
- const timingTrend = source.durationTrend.reportTimingMedian;
747
- lines.push(`${source.source}: ${states}; truncated ${source.truncationCount}; rate-limited ${source.rateLimitedCount}`
748
- + `; failure rate ${source.rates.failure ?? 'unmeasured'}; partial rate ${source.rates.partial ?? 'unmeasured'}`
749
- + (timing ? `; timing median ${timing.median} ms / p95 ${timing.p95} ms` : '')
750
- + (timingTrend ? `; report-median distribution ${timingTrend.minimum}-${timingTrend.maximum} ms` : '')
751
- + (source.durationTimeline.length ? `; timeline points ${source.durationTimeline.length}` : ''));
752
- }
753
- lines.push('Privacy: targets 0 · queries 0 · raw evidence 0');
746
+ const states = Object.entries(source.states).map(([state, count]) => `${state} ${count}`).join(', ') || 'no states retained';
747
+ lines.push('', source.source, ` Collection states: ${states}`, ` Samples: states ${source.samples.states} · observations ${source.samples.observations} · Lookup timings ${source.samples.timing}`, ` Exceptions: ${source.truncationCount} truncated · ${source.rateLimitedCount} rate-limited`, ` Rates (0–1): failure ${source.rates.failure ?? 'unmeasured'} · partial ${source.rates.partial ?? 'unmeasured'}`, ` Observation duration: ${duration(source.durationMs.observation)}`, ` Lookup timing: ${duration(source.durationMs.lookupTiming)}`);
748
+ if (source.durationTrend.reportObservationMedian)
749
+ lines.push(` Merged observation-median distribution: ${duration(source.durationTrend.reportObservationMedian)}`);
750
+ if (source.durationTrend.reportTimingMedian)
751
+ lines.push(` Merged Lookup-median distribution: ${duration(source.durationTrend.reportTimingMedian)}`);
752
+ if (source.durationTimeline.length)
753
+ lines.push(` Retained report timeline: ${source.durationTimeline.length} points`);
754
+ }
755
+ lines.push('', 'Privacy: targets 0 · queries 0 · raw evidence 0');
754
756
  for (const limitation of report.limitations)
755
757
  lines.push(`Limitation: ${limitation}`);
756
758
  return `${lines.join('\n')}\n`;
@@ -9,7 +9,7 @@ export const SUPPORT_ARGUMENT_PARSERS = Object.freeze({
9
9
  doctor: (parsed) => ({
10
10
  action: 'doctor',
11
11
  network: parsed.hasOption('--network'),
12
- output: jsonOutput(parsed),
12
+ output: parsed.hasOption('--summary-json') ? 'summary_json' : jsonOutput(parsed),
13
13
  ...terminalOptions(parsed),
14
14
  }),
15
15
  commands: (parsed) => ({
@@ -1,4 +1,5 @@
1
1
  import { RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA } from "../packages/contracts/risk-calibration.mjs";
2
+ import { DOCTOR_SCHEMA, DOCTOR_SUMMARY_SCHEMA } from "../packages/contracts/cli-doctor.mjs";
2
3
  import { positional, NO_POSITIONALS, OPTIONAL_FILE_POSITIONAL, constraint, EMPTY_CONSTRAINTS, commandSeed, text } from "./command-definition.mjs";
3
4
  export const SUPPORT_COMMAND_DEFINITIONS = Object.freeze({
4
5
  completion: commandSeed({
@@ -25,17 +26,17 @@ export const SUPPORT_COMMAND_DEFINITIONS = Object.freeze({
25
26
  reference: {
26
27
  description: 'Check the supported runtime and local terminal capabilities.',
27
28
  example: 'whoisleuth doctor --json',
28
- boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
29
+ boundary: 'The default check is offline. Public DNS, HTTPS and port 43 checks run only with --network. --summary-json omits private context and free-form diagnostic details.',
29
30
  },
30
31
  collection: { scope: 'Network access is opt-in with --network and is limited to fixed public DNS, HTTPS, and WHOIS diagnostics.' },
31
32
  summary: 'Check the local CLI runtime',
32
- options: ['--network', '--json', '--quiet', '--no-color'],
33
+ options: ['--network', '--json', '--summary-json', '--quiet', '--no-color'],
33
34
  positionals: NO_POSITIONALS,
34
- constraints: EMPTY_CONSTRAINTS,
35
+ constraints: Object.freeze([constraint({ kind: 'mutually_exclusive', options: ['--json', '--summary-json'] })]),
35
36
  handlerOwner: 'support',
36
37
  networkEffect: 'conditional_network',
37
38
  common: true,
38
- schemaIdentifiers: Object.freeze(['whoisleuth.cli.doctor']),
39
+ schemaIdentifiers: Object.freeze([DOCTOR_SCHEMA, DOCTOR_SUMMARY_SCHEMA]),
39
40
  primaryArtefacts: Object.freeze([]),
40
41
  planSupport: false,
41
42
  additionalOutputFormats: Object.freeze([]),
@@ -4,7 +4,7 @@ import { explainRiskScore, explainRiskScoreV8, RISK_MODEL_VERSION, RISK_REVIEW_T
4
4
  import { buildCliCommandCatalogue, formatCliCommandCatalogue, selectCliCommands } from "./command-catalogue.mjs";
5
5
  import { CLI_COMMAND_REGISTRY, CLI_COMMANDS, COMMAND_COLLECTION, COMMAND_DETAILS, COMMAND_USAGE, } from "./command-reference.mjs";
6
6
  import { buildShellCompletion } from "./completion.mjs";
7
- import { buildDoctorReport, formatDoctorReport } from "./doctor.mjs";
7
+ import { buildDoctorReport, buildDoctorSummary, formatDoctorReport } from "./doctor.mjs";
8
8
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
9
9
  import EXIT_CODES from "./exit-codes.mjs";
10
10
  import { formatJsonDocument } from "./formatters/json.mjs";
@@ -68,9 +68,11 @@ async function runDoctorCommand(args, dependencies, context) {
68
68
  ? await context.withProgress('Checking public DNS, HTTPS, and WHOIS connectivity', buildReport)
69
69
  : await buildReport();
70
70
  if (!args.quiet) {
71
- context.writeStdout(args.output === 'json'
72
- ? formatJsonDocument(report)
73
- : context.terminal(formatDoctorReport(report), args.color));
71
+ context.writeStdout(args.output === 'summary_json'
72
+ ? formatJsonDocument(buildDoctorSummary(report))
73
+ : args.output === 'json'
74
+ ? formatJsonDocument(report)
75
+ : context.terminal(formatDoctorReport(report), args.color));
74
76
  }
75
77
  return report.state === 'partial' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
76
78
  }
@@ -1,6 +1,7 @@
1
1
  import { INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES } from "./investigation-recipes.mjs";
2
2
  import { positional, OPTIONAL_FILE_POSITIONAL, constraint, commandSeed, integer, BASE_INTEGER_RANGE, file, optionDefinition } from "./command-definition.mjs";
3
3
  import { DOMAIN_FEED_CATALOGUE } from "../packages/monitoring/domain-feed.mjs";
4
+ import { WATCHLIST_SCHEMA_VERSION } from "../packages/contracts/workspace-portability.mjs";
4
5
  export const WORKFLOW_COMMAND_DEFINITIONS = Object.freeze({
5
6
  'domain-feed': commandSeed({
6
7
  reference: { description: 'Review exact hosts or literal terms in a selected local plain-domain feed.',
@@ -18,7 +19,7 @@ export const WORKFLOW_COMMAND_DEFINITIONS = Object.freeze({
18
19
  }),
19
20
  'watchlist-review': commandSeed({
20
21
  reference: { description: 'Preview or export an explicit candidate selection to a local watchlist.', example: 'whoisleuth watchlist-review plan selection.json --json',
21
- boundary: 'Reads bounded local JSON only. Plan shows exact domains, duplicates, per-domain outcomes, destination and review priority with zero additional requests. Export emits Watchlist schema 5 without creating a scan or baseline. Neither mode enables collection or scheduling.' },
22
+ boundary: `Reads bounded local JSON only. Plan shows exact domains, duplicates, per-domain outcomes, destination and review priority with zero additional requests. Export emits Watchlist schema ${WATCHLIST_SCHEMA_VERSION} without creating a scan or baseline. Neither mode enables collection or scheduling.` },
22
23
  collection: { scope: 'At most 2,000 selected candidate domains; no target requests.' },
23
24
  summary: 'Review a local candidate watchlist handoff', options: ['--json', '--quiet', '--no-color'],
24
25
  positionals: Object.freeze([positional('operation', 'enum', 1, 1, ['plan', 'export']), positional('source', 'file', 0, 1, [], 'argv_or_stdin')]),
@@ -88,6 +88,7 @@ async function runMonitorOnceCommand(args, dependencies, context) {
88
88
  const executeLookup = dependencies.runUnifiedLookup || (await import("../lib/lookup.mjs")).runUnifiedLookup;
89
89
  const progress = context.beginProgress('Collecting bounded domain-control evidence');
90
90
  let document;
91
+ let currentSourceFailureCount = 0;
91
92
  try {
92
93
  document = await runDomainControlMonitor(manifestInput, previousInput, {
93
94
  executeLookup,
@@ -95,6 +96,9 @@ async function runMonitorOnceCommand(args, dependencies, context) {
95
96
  limit: args.limit,
96
97
  concurrency: args.concurrency,
97
98
  ...(dependencies.signal ? { signal: dependencies.signal } : {}),
99
+ ...(args.failOn?.includes('source-failure') ? {
100
+ onSourceFailures: (count) => { currentSourceFailureCount = count; },
101
+ } : {}),
98
102
  onSettled: (completed, total) => progress.update(`Collected ${completed} of ${total} owned domains`),
99
103
  });
100
104
  }
@@ -108,7 +112,7 @@ async function runMonitorOnceCommand(args, dependencies, context) {
108
112
  ? formatCliJunit(document)
109
113
  : context.terminal(formatDomainControlMonitor(document), args.color));
110
114
  }
111
- const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
115
+ const policyFindings = evaluateCliFailPolicies(document, args.failOn || [], null, currentSourceFailureCount);
112
116
  if (policyFindings.length)
113
117
  context.writeStderr(formatFailPolicyNotice(policyFindings));
114
118
  return document.collection.failed || policyFindings.length
@@ -71,11 +71,11 @@ candidate, form destination and deliberately observed navigation separately
71
71
  attributed. A similar page without a logo or brand name can still merit manual
72
72
  evidence review; correspondence does not establish authorship, rights authority
73
73
  or maliciousness. Missing optional capture or comparison evidence does not
74
- prevent ordinary Case pins and conclusions. Installed command help remains the
74
+ prevent ordinary case pins and conclusions. Installed command help remains the
75
75
  authority for offline file inputs and explicit collection plans.
76
76
 
77
77
  The [reporting guide](https://www.whoisleuth.com/resources/reporting-and-takedown-guidance)
78
- uses the same reviewed official-route catalogue as the Console for social
78
+ uses the same reviewed official-route catalogue as the console for social
79
79
  content, storefronts, app listings, hosted files/forms and advertising. Verify
80
80
  the exact object, current route, reporter authority and disclosure before any
81
81
  manual submission. Fraud, order complaints and rights notices are different
@@ -96,8 +96,10 @@ PKIX, STARTTLS and SMTP transport remain separate evidence states.
96
96
  STARTTLS detection uses the full bounded reply. If the retained capability
97
97
  inventory is shortened, the report remains partial and names the omission.
98
98
 
99
- The repository-only rendered-capture package is outside hosted and distributable
100
- collection. It executes remote page JavaScript only for an explicitly
99
+ The separate optional rendered-capture companion is distributed as a locally
100
+ assembled archive, not as part of the hosted application or main CLI, and is
101
+ not published to a package registry. Its sandboxed, bounded browser executes
102
+ remote page JavaScript only for an explicitly
101
103
  authorised route set, and each admitted resource operator receives the exact
102
104
  requested URL. Captures and screenshots remain local until the operator deletes
103
105
  them.
@@ -126,6 +128,13 @@ source state is unmeasured, not a negative result. `--csv` keeps the compact col
126
128
  `--fail-on` and `--strict-exit` expose selected evidence states to automation
127
129
  without changing the result document. Review focused help for the policies a
128
130
  command supports.
131
+ Lookup, Bulk and Discover scan evaluate all collected and restored evidence,
132
+ not only the rows retained by a filter. `danger` uses the canonical Risk model;
133
+ select `--deep --brand-profile brand.json` for local reference comparisons, adding
134
+ `--brand-id <exact-profile-id>` for a multi-profile export. A match returns 4 and identifies model and evidence
135
+ quality on stderr. Missing/ineligible evidence is not zero risk and does not
136
+ implicitly select another failure policy. See the
137
+ [automation examples and limits](cli.md#formats-and-exit-behaviour).
129
138
 
130
139
  | Code | Meaning |
131
140
  | ---: | --- |
@@ -143,18 +152,46 @@ Readers accept only their declared public and current versions. Unknown,
143
152
  unreleased historical and future schemas fail before partial interpretation;
144
153
  an invalid import is not treated as an empty document.
145
154
 
146
- `inspect-archive` reads current workspace archive v9 and exact versions 5–8. It
155
+ `inspect-archive` reads current workspace archive v10 and exact versions 5–9. It
147
156
  reports section metadata and digest-only search results unless `--reveal` is
148
157
  explicitly selected. It never searches notes, contacts or arbitrary raw fields.
149
158
 
150
- `export` reads supported saved Lookup v1 or v2 and writes current Lookup
151
- evidence schema 29. Published v2 schemas 27 and 28 and exact v1 schema 26 remain
152
- readable. Versions 27–29 exclude raw registration payloads, expanded
159
+ `export` reads supported saved Lookup versions 1–3 and writes current Lookup
160
+ evidence schema 31. Supported schemas 27–30 and exact v1 schema 26 remain
161
+ readable. Versions 27–31 exclude raw registration payloads, expanded
153
162
  contacts, credentials, complete query-bearing URLs and provider payloads;
154
- schemas 28 and 29 also retain the bounded registrar-standing projection.
163
+ Schemas 28 and later also retain the bounded registrar-standing projection.
155
164
  Schema 29 identifies the hostname used for DNS, TLS and web observations,
156
165
  separately from the registrable domain used by registration sources. Older
157
166
  evidence retains its original collection scope.
167
+ Schema 31 retains WHOIS registration classification separately from collection
168
+ completion. Lifecycle interpretation requires positive registration evidence;
169
+ a completed negative or inconclusive WHOIS reply does not establish registration
170
+ or absence of locks. Earlier exports retain their exact original interpretation
171
+ for historical verification and are not silently relabelled.
172
+
173
+ New WHOIS collection distinguishes confirmed no-service discovery from an
174
+ incomplete discovery response. Empty, refused or unrecognised bootstrap
175
+ responses remain partial; they do not establish that WHOIS is unsupported.
176
+ `--strict-exit` treats that incomplete attempt as a source failure. Explicit
177
+ no-request access-policy restrictions remain distinct from transport failures.
178
+
179
+ Saved Lookup version 3 can retain a bounded collection receipt: original
180
+ optional selections, admitted source families, collector starts, outcomes and
181
+ explicitly known completeness. `lookup --verbose` and `lookup --browse`
182
+ show this account with source-specific explanations and qualified retry guidance.
183
+ Earlier files have no inferred receipt. The receipt contains
184
+ no target values, URLs, contacts or raw errors and does not prove network
185
+ delivery. It stays in full private Lookup JSON; the minimised `export` report
186
+ does not include it. `--plan` remains offline and produces no completed receipt.
187
+
188
+ Case schema 20 also retains this context when an analyst saves a completed Lookup
189
+ assessment or selected checkpoint facts. A selected checkpoint saves only its
190
+ selected pins and one payload-free receipt, not every Lookup result. Ordinary
191
+ backups and internal case packs preserve it. Trusted/public case packs, case
192
+ reports and response packets omit receipts; shared assessment identities are
193
+ recomputed without them. Historical case formats remain readable without
194
+ inventing missing collection choices.
158
195
 
159
196
  `verify-artifact` checks a recognised structure and its applicable integrity
160
197
  contract. `interchange-report` describes retained and omitted fields.
@@ -162,6 +199,14 @@ contract. `interchange-report` describes retained and omitted fields.
162
199
  validity and signer trust separate. None of these checks establishes that
163
200
  evidence is accurate, current, safe to share or attributable to a person.
164
201
 
202
+ The text output of `verify-artifact` suggests compatible offline follow-up
203
+ commands for recognised saved Lookups, case exports, workspace archives,
204
+ Lookup-evidence reports, Case-packs and managed indicator revisions. These are
205
+ examples with placeholder filenames, not executed actions or a sharing approval.
206
+ Replace the filenames deliberately; a case or indicator inspection can display
207
+ private contents. Encrypted workspace guidance uses a separate passphrase file,
208
+ never a passphrase in command history. JSON output and `--quiet` are unchanged.
209
+
165
210
  ### Signer trust
166
211
 
167
212
  Use `verify-signature package.json --trust-store-file trust.json --json` to
@@ -211,9 +256,9 @@ own retention policy. No automatic key discovery, key storage or trust refresh o
211
256
  control, activity, intent or maliciousness.
212
257
  - Bulk applies one declared collection contract per target. Fast accepts up to
213
258
  500 targets and Deep up to 50; each target remains a separate request.
214
- - Respond commands package browser-created Cases and prepare local packets,
259
+ - Respond commands package browser-created cases and prepare local packets,
215
260
  reports and sharing reviews. The offline [`case` workflow](cli.md#local-case-files)
216
- also creates and updates ordinary local Case files through explicit, conflict-checked
261
+ also creates and updates ordinary local case files through explicit, conflict-checked
217
262
  output. Neither workflow submits, publishes, notifies or grants recipient authorisation.
218
263
  - Assurance, comparison and calibration commands describe supplied or retained
219
264
  evidence. They do not tune the running model, change infrastructure or turn an