@slicedearth/whoisleuth-cli 2.6.0 → 2.7.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 (84) 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/case-command.mjs +17 -17
  8. package/cli/case-pack.mjs +7 -1
  9. package/cli/ci-report.mjs +17 -5
  10. package/cli/command-definition.mjs +5 -5
  11. package/cli/doctor.mjs +37 -3
  12. package/cli/formatters/html.mjs +3 -0
  13. package/cli/formatters/json.mjs +8 -1
  14. package/cli/formatters/terminal-lookup.mjs +24 -1
  15. package/cli/history-command-definitions.mjs +1 -1
  16. package/cli/investigation-plan.mjs +15 -0
  17. package/cli/lookup-brief.mjs +11 -5
  18. package/cli/lookup-command-runner.mjs +20 -13
  19. package/cli/review-command-definitions.mjs +11 -11
  20. package/cli/saved-lookup.mjs +22 -7
  21. package/cli/source-reliability.mjs +12 -10
  22. package/cli/support-arguments.mjs +1 -1
  23. package/cli/support-command-definitions.mjs +5 -4
  24. package/cli/support-command-runner.mjs +6 -4
  25. package/cli/workflow-command-definitions.mjs +2 -1
  26. package/docs/cli-reference.md +49 -11
  27. package/docs/cli.md +109 -32
  28. package/docs/contextual-reviews.md +10 -0
  29. package/docs/infrastructure-observations.md +9 -0
  30. package/lib/dmarc-discovery.mjs +4 -2
  31. package/lib/dns-inheritance-review.mjs +14 -1
  32. package/lib/evidence-export.mjs +10 -4
  33. package/lib/evidence-report.mjs +2 -1
  34. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  35. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  36. package/lib/generated/retire-browser-catalog.mjs +178 -5
  37. package/lib/idn-confusables.mjs +5 -1
  38. package/lib/lookup-collection-receipt.mjs +52 -0
  39. package/lib/lookup-diagnostics.mjs +2 -12
  40. package/lib/lookup-progress.mjs +29 -31
  41. package/lib/lookup-response-contract.mjs +9 -0
  42. package/lib/lookup-response.mjs +15 -14
  43. package/lib/lookup-source-progress.mjs +4 -2
  44. package/lib/lookup.mjs +33 -2
  45. package/lib/rdap-capabilities.mjs +43 -9
  46. package/lib/registry-insights.mjs +17 -6
  47. package/lib/sslbl-certificates.generated.mjs +143 -142
  48. package/lib/whois-authority.mjs +56 -1
  49. package/lib/whois-chain.mjs +2 -1
  50. package/lib/whois-contracts.mjs +2 -0
  51. package/lib/whois-normalization.mjs +4 -4
  52. package/package.json +6 -6
  53. package/packages/analysis/bounded-json.mjs +12 -2
  54. package/packages/cases/case-attachment-model.mjs +36 -1
  55. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  56. package/packages/cases/case-evidence-links.mjs +28 -0
  57. package/packages/cases/case-evidence-model.mjs +39 -6
  58. package/packages/cases/case-migration-model.mjs +9 -1
  59. package/packages/cases/case-object-coverage.mjs +106 -20
  60. package/packages/cases/case-record-operations.mjs +13 -0
  61. package/packages/cases/case-record-projection.mjs +28 -2
  62. package/packages/cases/case-report.mjs +5 -4
  63. package/packages/cases/case-response-packet-validation.mjs +880 -0
  64. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  65. package/packages/cases/case-response-packet.mjs +2 -15
  66. package/packages/cases/case-response-review-inputs.mjs +18 -1
  67. package/packages/cases/case-type-presentation.mjs +10 -0
  68. package/packages/cases/case-workflow-metadata.mjs +0 -8
  69. package/packages/comparison/capture-context.mjs +3 -2
  70. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  71. package/packages/contracts/case-portability.mjs +59 -28
  72. package/packages/contracts/cli-doctor.mjs +4 -0
  73. package/packages/contracts/cli-lookup.mjs +57 -46
  74. package/packages/contracts/lookup-evidence.mjs +12 -2
  75. package/packages/contracts/registry-insights.mjs +6 -0
  76. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  77. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  78. package/packages/evidence/lookup-collection-review.mjs +74 -0
  79. package/packages/interchange/external-findings-import.mjs +8 -4
  80. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  81. package/packages/investigation/domain-history-review.mjs +7 -1
  82. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  83. package/packages/workspace/bulk-session-model.mjs +8 -0
  84. package/third-party-notices.txt +29 -29
@@ -1,11 +1,12 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
2
+ import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, lookupEvidenceRegistryInsightsVersion, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
3
3
  import { validLookupObservationScope } from "../../packages/evidence/lookup-target.mjs";
4
4
  import { externalPasswordFormObservation } from "../../packages/evidence/credential-form-attribution.mjs";
5
5
  import { credentialSurfaceContractState } from "../../lib/lookup-child-profile-contract.mjs";
6
6
  import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../../lib/registrar-standing-contract.mjs";
7
7
  import { compareRegistrySources } from "../../lib/registry-comparison.mjs";
8
8
  import { buildRegistryInsights } from "../../lib/registry-insights.mjs";
9
+ import { resolveRdapCapabilityCatalogRevision } from "../../lib/rdap-capabilities.mjs";
9
10
  import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../../packages/analysis/project-metadata.mjs";
10
11
  import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../../lib/homepage-metadata-contract.mjs";
11
12
  import { HEX_DIGEST_RE, SEMANTIC_VERSION_RE, absoluteUrl, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, nullableRecord, optionalText, record, strings, text, } from "./structure-primitives.mjs";
@@ -344,7 +345,7 @@ function validateLookupEvidenceWhois(value, version) {
344
345
  optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
345
346
  nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
346
347
  if (version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
347
- && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed))) {
348
+ && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed, version))) {
348
349
  fail('Lookup evidence WHOIS portable publication');
349
350
  }
350
351
  array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
@@ -715,6 +716,15 @@ export function validateLookupEvidenceArtifactStructure(value) {
715
716
  });
716
717
  if (!isDeepStrictEqual(registryComparison, expectedComparison))
717
718
  fail('Lookup evidence registry comparison derivation');
719
+ const capabilities = record(registryInsights.rdapCapabilities, 'Lookup evidence RDAP capabilities');
720
+ const registryCapabilities = record(capabilities.registry, 'Lookup evidence registry RDAP capabilities');
721
+ const registrarCapabilities = record(capabilities.registrar, 'Lookup evidence registrar RDAP capabilities');
722
+ const catalogReviewedAt = registryCapabilities.catalogReviewedAt;
723
+ if (typeof catalogReviewedAt !== 'string'
724
+ || resolveRdapCapabilityCatalogRevision(catalogReviewedAt) === null
725
+ || registrarCapabilities.catalogReviewedAt !== catalogReviewedAt) {
726
+ fail('Lookup evidence RDAP capability catalogue revision');
727
+ }
718
728
  const rebuiltInsights = buildRegistryInsights({
719
729
  rdapParsed,
720
730
  rdapStatus: rdapDiagnosticStatus,
@@ -722,6 +732,9 @@ export function validateLookupEvidenceArtifactStructure(value) {
722
732
  whoisParsed,
723
733
  whoisStatus: whoisDiagnosticStatus,
724
734
  whoisQueriedAt: whois.queriedAt,
735
+ }, {
736
+ rdapCapabilityCatalogReviewedAt: catalogReviewedAt,
737
+ version: lookupEvidenceRegistryInsightsVersion(version),
725
738
  });
726
739
  const expectedInsights = version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
727
740
  ? projectLookupEvidenceRegistryInsights(rebuiltInsights)
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION } from "../packages/contracts/analyst-interchange.mjs";
2
+ import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION, MAX_MANAGED_INDICATOR_SET_BYTES } from "../packages/contracts/analyst-interchange.mjs";
3
3
  import { createHash } from 'node:crypto';
4
4
  import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
5
5
  import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "./bounded-json.mjs";
@@ -532,6 +532,34 @@ export async function verifyOfflineArtifact(raw, options = {}) {
532
532
  : null;
533
533
  return Object.freeze({ ...report, manifestIdentity });
534
534
  }
535
+ function offlineArtifactNextSteps(report) {
536
+ switch (report.artifact.kind) {
537
+ case 'saved_lookup':
538
+ return ['Review retained source states without displaying target values:', ' whoisleuth source-report -- ./lookup.json'];
539
+ case 'case_export':
540
+ return ['Read the Case records, including private analyst content:', ' whoisleuth case show -- ./cases.json'];
541
+ case 'workspace_archive':
542
+ return ['Inspect section metadata without restoring the archive:', ' whoisleuth inspect-archive -- ./workspace.json'];
543
+ case 'encrypted_workspace_archive':
544
+ return report.checks.authenticatedEncryption === 'verified'
545
+ ? ['Inspect decrypted section metadata without restoring the archive:', ' whoisleuth inspect-archive --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json']
546
+ : ['Check the encrypted contents using a separate passphrase file:', ' whoisleuth verify-artifact --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json'];
547
+ case 'lookup_evidence':
548
+ return ['Review which fields this portable format preserves and omits:', ' whoisleuth interchange-report -- ./evidence.json'];
549
+ case 'cli_case_pack':
550
+ return ['Review the Case-pack compatibility and field exclusions:', ' whoisleuth interchange-report -- ./case-pack.json'];
551
+ case 'signed_review_artifact':
552
+ if (report.artifact.schema === DOMAIN_CONTROL_MANIFEST_SCHEMA || report.artifact.schema === LOOKUP_CLAIM_PASSPORT_SCHEMA) {
553
+ return ['Review the passport compatibility and retained field groups:', ' whoisleuth interchange-report -- ./passport.json'];
554
+ }
555
+ return report.artifact.schema === MANAGED_INDICATOR_SET_SCHEMA
556
+ && report.summary.inputBytes <= MAX_MANAGED_INDICATOR_SET_BYTES
557
+ ? ['Read the retained indicator identities and review windows:', ' whoisleuth indicator-set inspect -- ./indicators.json']
558
+ : [];
559
+ default:
560
+ return [];
561
+ }
562
+ }
535
563
  export function formatOfflineArtifactVerification(report) {
536
564
  const lines = [
537
565
  'WHOISleuth offline artefact verification',
@@ -611,5 +639,8 @@ export function formatOfflineArtifactVerification(report) {
611
639
  }
612
640
  for (const limitation of report.limitations)
613
641
  lines.push(`Limitation: ${limitation}`);
642
+ const nextSteps = offlineArtifactNextSteps(report);
643
+ if (nextSteps.length)
644
+ lines.push('', 'Offline next steps (examples; replace filenames; nothing runs automatically):', ...nextSteps);
614
645
  return `${lines.join('\n')}\n`;
615
646
  }
@@ -29,12 +29,12 @@ function prose(input, maximum, label) {
29
29
  function selection(cases, args, required) {
30
30
  const domain = args.domain === null ? null : normalizeDomain(args.domain);
31
31
  if (args.domain !== null && !domain)
32
- throw new CliUsageError('Select a valid Case domain.');
32
+ throw new CliUsageError('Select a valid case domain.');
33
33
  const selected = cases.filter(record => (!args.caseId || record.id === args.caseId) && (!domain || record.domain === domain));
34
34
  if ((args.caseId || domain || required) && selected.length === 0)
35
- throw new CliUsageError('The selected Case is not present in this file.');
35
+ throw new CliUsageError('The selected case is not present in this file.');
36
36
  if (required && selected.length !== 1)
37
- throw new CliUsageError('Select exactly one Case with --case-id; no arbitrary Case was chosen.');
37
+ throw new CliUsageError('Select exactly one case with --case-id; no arbitrary case was chosen.');
38
38
  return selected;
39
39
  }
40
40
  function pin(input, now) {
@@ -42,7 +42,7 @@ function pin(input, now) {
42
42
  throw new CliUsageError('An evidence pin must be a JSON object.');
43
43
  const raw = input;
44
44
  if (Object.hasOwn(raw, 'id') || Object.hasOwn(raw, 'createdAt'))
45
- throw new CliUsageError('New pin identity and creation time are allocated by the Case operation.');
45
+ throw new CliUsageError('New pin identity and creation time are allocated by the case operation.');
46
46
  const normal = appendCaseEvidencePin([], raw, now)[0];
47
47
  // Validate the supplied subset against the domain result, without duplicating
48
48
  // its field inventory or accepting silent truncation/default substitution.
@@ -86,7 +86,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
86
86
  ? createCaseIncident(cases, { domain: args.domain, title }, now)
87
87
  : openOrCreateCase(cases, { domain: args.domain, ...(title ? { title } : {}) }, now, args.caseId ? { caseId: args.caseId } : {});
88
88
  if (!result.created && title && title !== result.record.title)
89
- throw new CliUsageError('The selected Case already exists with another title. Use --new-incident for a separate investigation.');
89
+ throw new CliUsageError('The selected case already exists with another title. Use --new-incident for a separate investigation.');
90
90
  return result.cases;
91
91
  }
92
92
  const current = selection(cases, args, true)[0];
@@ -113,7 +113,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
113
113
  result = updateCase(cases, current.id, { action: input }, now);
114
114
  const action = result.record.actions.find(candidate => !current.actions.some(prior => prior.id === candidate.id));
115
115
  if (!action)
116
- throw new CliUsageError('No new action was retained within the Case bounds; nothing was written.');
116
+ throw new CliUsageError('No new action was retained within the case bounds; nothing was written.');
117
117
  for (const [key, value] of Object.entries(input))
118
118
  if (canonicalArtifactJsonV2(key === 'responseObjects' ? readCaseResponseObjects(value) : value) !== canonicalArtifactJsonV2(action[key])) {
119
119
  throw new CliUsageError(`Action field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
@@ -142,7 +142,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
142
142
  result = updateCase(cases, current.id, { assertion: { ...input, kind: 'next_step' } }, now);
143
143
  const question = result.record.assertions.find(candidate => !current.assertions.some(prior => prior.id === candidate.id));
144
144
  if (!question)
145
- throw new CliUsageError('No new question was retained within the Case bounds; nothing was written.');
145
+ throw new CliUsageError('No new question was retained within the case bounds; nothing was written.');
146
146
  for (const [key, value] of Object.entries(input))
147
147
  if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(question[key])) {
148
148
  throw new CliUsageError(`Question field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
@@ -150,12 +150,12 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
150
150
  }
151
151
  else if (args.operation === 'close-object') {
152
152
  if (!input?.responseObject)
153
- throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole Case.');
153
+ throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole case.');
154
154
  fields(input, ['responseObject', 'reason', 'summary', 'observedEffectReviewId', 'actionId', 'limitations'], 'Object closure');
155
155
  result = updateCase(cases, current.id, { closure: input }, now);
156
156
  const closure = result.record.closures.records.find(candidate => !current.closures.records.some(prior => prior.id === candidate.id));
157
157
  if (!closure)
158
- throw new CliUsageError('No new closure was retained within the Case bounds; nothing was written.');
158
+ throw new CliUsageError('No new closure was retained within the case bounds; nothing was written.');
159
159
  for (const [key, value] of Object.entries(input))
160
160
  if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(closure[key])) {
161
161
  throw new CliUsageError(`Closure field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
@@ -191,7 +191,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
191
191
  fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname', 'responseObject', 'objectOutcome'], 'Recheck');
192
192
  const recheck = readCaseRecheckAnswerContext(input.recheck);
193
193
  if (input.state === 'not_reproduced' && !recheck) {
194
- throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned Case or record unavailable for a limited observation.');
194
+ throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned case or record unavailable for a limited observation.');
195
195
  }
196
196
  const summary = prose(input.comparisonSummary, MAX_RESPONSE_VALUE_LENGTH, 'Comparison summary');
197
197
  result = recordCaseRecheckOutcome(cases, current.id, {
@@ -214,12 +214,12 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
214
214
  }
215
215
  }
216
216
  else
217
- throw new CliUsageError('Select a supported Case mutation.');
217
+ throw new CliUsageError('Select a supported case mutation.');
218
218
  retainExistingEntries(current, result.record);
219
219
  return result.cases;
220
220
  }
221
221
  function formatCases(cases, digest) {
222
- const lines = ['Local Case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
222
+ const lines = ['Local case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
223
223
  for (const record of cases) {
224
224
  lines.push(`Case ${safeTerminalValue(record.id)} · ${safeTerminalValue(record.title || record.domain)}`, `Domain ${safeTerminalValue(record.domain)}`, `Status ${statusLabel(record.status)}`, `Disposition ${dispositionLabel(record.disposition)} (analyst assessment)`, `Updated ${record.updatedAt}`);
225
225
  for (const note of record.notes)
@@ -246,16 +246,16 @@ function formatCases(cases, digest) {
246
246
  lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`, ` Object: ${review.responseObject ? `${review.responseObject.kind}: ${safeTerminalValue(review.responseObject.identifier)}` : 'unknown binding'}${review.objectOutcome ? ` · observed ${review.objectOutcome}` : ''}`);
247
247
  lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
248
248
  }
249
- lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
249
+ lines.push('Use --json for the full case export. Attached file bytes remain separate. This command makes no request.', '');
250
250
  return lines.join('\n');
251
251
  }
252
252
  export async function runCaseCommand(args, dependencies, context) {
253
- context.setFailureLabel('Local Case operation');
253
+ context.setFailureLabel('Local case operation');
254
254
  try {
255
255
  const raw = args.source ? dependencies.caseFileInput ?? await context.readInput(args.source, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case file') : null;
256
256
  const digest = raw === null ? null : `sha256:${createHash('sha256').update(raw).digest('hex')}`;
257
257
  if (args.expectedFileDigest && args.expectedFileDigest !== digest)
258
- throw new CliUsageError('The Case file differs from the reviewed file digest. Nothing was written.');
258
+ throw new CliUsageError('The case file differs from the reviewed file digest. Nothing was written.');
259
259
  const cases = raw === null ? [] : readEditableCaseExport(raw);
260
260
  const now = normalizeExplicitIsoTimestamp(context.now());
261
261
  if (!now)
@@ -271,7 +271,7 @@ export async function runCaseCommand(args, dependencies, context) {
271
271
  const note = args.noteSource ? await context.readInput(args.noteSource, MAX_NOTE_LENGTH * 4 + 4, 'Case note') : args.text;
272
272
  const next = applyCliCaseOperation(cases, args, input, note, now);
273
273
  if (Buffer.byteLength(serializeCaseStore(next)) > MAX_CASE_STORE_BYTES)
274
- throw new CliUsageError('The updated Case store exceeds its byte budget. No evidence was pruned and nothing was written.');
274
+ throw new CliUsageError('The updated case store exceeds its byte budget. No evidence was pruned and nothing was written.');
275
275
  const output = `${JSON.stringify(buildCaseExport(next, now))}\n`;
276
276
  // Apply the same strict admission to our output before atomic publication.
277
277
  readEditableCaseExport(output);
@@ -283,6 +283,6 @@ export async function runCaseCommand(args, dependencies, context) {
283
283
  dependencies.signal?.throwIfAborted();
284
284
  if (cause instanceof CliUsageError)
285
285
  throw cause;
286
- throw new CliUsageError(cause instanceof Error ? cause.message : 'The Case operation could not be completed.');
286
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'The case operation could not be completed.');
287
287
  }
288
288
  }
package/cli/case-pack.mjs CHANGED
@@ -5,7 +5,7 @@ import { buildCaseReport, buildCaseReportVerificationProjection } from "../packa
5
5
  import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
6
6
  import { assertBoundedJsonStructure, scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
7
7
  import { assertCaseBrandProfileIds } from "../packages/cases/case-brand-profile-references.mjs";
8
- import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, } from "../packages/cases/case-record-projection.mjs";
8
+ import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, assertCaseCollectionReceiptAudience, } from "../packages/cases/case-record-projection.mjs";
9
9
  import { normalizeDomain } from "../packages/evidence/domain-name.mjs";
10
10
  import { normalizeCaseStore } from "../packages/cases/case-migration-model.mjs";
11
11
  import { safeId } from "../packages/cases/case-record-core.mjs";
@@ -83,6 +83,11 @@ function allowedSensitiveFieldPath(key, path) {
83
83
  return listItemPath(path, 'actions');
84
84
  if (key === 'packetReceipt')
85
85
  return typeof path.at(-1) === 'number' && path.at(-2) === 'history' && listItemPath(path.slice(0, -2), 'actions');
86
+ if (key === 'checkpointCollectionReceipts')
87
+ return topCasePath(path);
88
+ if (key === 'collectionReceipt')
89
+ return path.length === 4 && path[0] === 'cases' && typeof path[1] === 'number'
90
+ && (path[2] === 'evidenceHistory' || path[2] === 'checkpointCollectionReceipts') && typeof path[3] === 'number';
86
91
  return true;
87
92
  }
88
93
  /** Rejects hidden audience-sensitive copies with a budget proven by the serialized byte bound. */
@@ -390,6 +395,7 @@ export function verifyCliCasePack(input) {
390
395
  assertClosedEnvelopes(root, packet, integrity, redactionManifest, packet.reports, root.version);
391
396
  assertSensitiveFieldPlacement(root, serialized.length);
392
397
  const audience = packet.audience;
398
+ assertCaseCollectionReceiptAudience(root, audience);
393
399
  if (!Number.isSafeInteger(redactionManifest.sourceCaseCount)
394
400
  || redactionManifest.sourceCaseCount !== root.cases.length
395
401
  || !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
package/cli/ci-report.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { WHOISLEUTH_REQUEST_POLICY_URL } from "../packages/analysis/project-metadata.mjs";
2
2
  import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA } from "../packages/contracts/domain-control-monitor.mjs";
3
+ import { lookupDiagnosticEntries } from "../lib/lookup-progress.mjs";
4
+ import { lookupStrictExitFindings } from "./strict-exit.mjs";
3
5
  function record(value) {
4
6
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
5
7
  }
@@ -9,13 +11,23 @@ function xml(value) {
9
11
  function junitCases(document) {
10
12
  const schema = String(document.schema || '');
11
13
  if (schema === 'whoisleuth.cli.lookup') {
12
- const diagnostics = record(document.diagnostics);
13
- const cases = Object.entries(diagnostics).slice(0, 64).map(([source, value]) => {
14
- const state = String(record(value).status ?? record(value).state ?? 'unavailable').toLowerCase();
15
- return ['error', 'failed', 'partial', 'rate_limited', 'timeout', 'unavailable'].includes(state)
16
- ? { name: `source ${source}`, failure: `Source state: ${state}` }
14
+ const sources = lookupDiagnosticEntries(document.diagnostics).filter(source => source.present);
15
+ const findings = lookupStrictExitFindings(document);
16
+ const cases = sources.map(({ source, path, state }) => {
17
+ const failure = findings.find(finding => finding.path === path);
18
+ return failure || state === null
19
+ ? { name: `source ${source}`, failure: `Source state: ${failure?.state ?? 'unavailable'}` }
17
20
  : { name: `source ${source}` };
18
21
  });
22
+ // Retain nested and historical failure coverage without echoing imported
23
+ // object keys, endpoints or evidence into CI names or messages.
24
+ const namedPaths = new Set(sources.map(source => source.path));
25
+ const additional = findings.filter(finding => !namedPaths.has(finding.path));
26
+ if (additional.length)
27
+ cases.push({
28
+ name: 'additional source diagnostics',
29
+ failure: `Source states: ${[...new Set(additional.map(finding => finding.state))].sort().join(', ')}`,
30
+ });
19
31
  return cases.length ? cases : [{ name: 'lookup diagnostics', failure: 'No source diagnostics were available.' }];
20
32
  }
21
33
  if (schema === 'whoisleuth.cli.bulk') {
@@ -126,7 +126,7 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
126
126
  '--config': file('Load explicit versioned CLI configuration from this file.'),
127
127
  '--profile': text('Select a named profile from the supplied configuration.', true),
128
128
  '--palette': enumeration('Choose the terminal colour palette; redirected output and no-colour settings still take precedence.', ['auto', 'light', 'dark']),
129
- '--network': flag('Include the optional public DNS and port 43 runtime checks.'),
129
+ '--network': flag('Include the optional public DNS, HTTPS and WHOIS (port 43) runtime checks.'),
130
130
  '--json': flag('Write structured JSON to stdout or the selected output file.'),
131
131
  '--reported-action': optionDefinition('enum', 'Record an analyst-reported identity action; repeat for separate actions.', { values: IDENTITY_ACTIONS.map(action => action.id), occurrence: 'repeatable' }),
132
132
  '--trusted-auth-header': optionDefinition('text', 'Select a recognised receiver header by part:header-index. This records analyst trust, not independent authentication; repeat for separate headers.', { occurrence: 'repeatable' }),
@@ -235,10 +235,10 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
235
235
  '--left-session': text('Select the left-hand retained capture session.', true),
236
236
  '--right-session': text('Select the right-hand retained capture session.', true),
237
237
  '--compact': flag('Write a compact report presentation.'),
238
- '--case-id': text('Select the retained Case identifier.'),
239
- '--domain': text('Supply the Case domain.'),
240
- '--title': text('Supply the Case title.'),
241
- '--new-incident': flag('Create a separate incident instead of updating a matching Case.'),
238
+ '--case-id': text('Select the retained case identifier.'),
239
+ '--domain': text('Supply the case domain.'),
240
+ '--title': text('Supply the case title.'),
241
+ '--new-incident': flag('Create a separate incident instead of updating a matching case.'),
242
242
  '--text': text('Supply the note text directly.'),
243
243
  '--note-file': file('Read note text from a selected local file.'),
244
244
  '--input': file('Read the selected local input file.'),
package/cli/doctor.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  import { withTimeout } from "../lib/abort.mjs";
2
- export const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
3
- export const DOCTOR_VERSION = 1;
2
+ import { CLI_COMMAND_REGISTRY } from "./command-reference.mjs";
3
+ import { DOCTOR_SCHEMA, DOCTOR_VERSION, DOCTOR_SUMMARY_SCHEMA, DOCTOR_SUMMARY_VERSION } from "../packages/contracts/cli-doctor.mjs";
4
+ export { DOCTOR_SCHEMA, DOCTOR_VERSION, DOCTOR_SUMMARY_SCHEMA, DOCTOR_SUMMARY_VERSION };
4
5
  const MINIMUM_NODE_MAJOR = 24;
5
6
  const NETWORK_HOST = 'whois.iana.org';
6
7
  const NETWORK_QUERY = 'example.com';
7
8
  const NETWORK_HTTPS_URL = 'https://data.iana.org/rdap/dns.json';
8
9
  const NETWORK_TIMEOUT_MS = 6_000;
9
10
  const MAX_DOCTOR_DETAIL_LENGTH = 180;
11
+ const DOCTOR_CHECK_IDS = ['runtime', 'platform', 'terminal', 'offline_features', 'network', 'public_dns', 'https_transport', 'whois_transport'];
10
12
  function boundedDetail(value, fallback) {
11
13
  return String(value || fallback)
12
14
  .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
@@ -162,6 +164,38 @@ async function buildDoctorReport(options) {
162
164
  checks: Object.freeze(checks),
163
165
  });
164
166
  }
167
+ function diagnosticVersion(value) {
168
+ return /^(\d{1,6}\.\d{1,6}\.\d{1,6})(?:[-+].*)?$/u.exec(value)?.[1] ?? null;
169
+ }
170
+ function buildDoctorSummary(report, nodeVersion = process.versions.node) {
171
+ const checkCodes = DOCTOR_CHECK_IDS.flatMap(id => {
172
+ const check = report.checks.find(item => item.id === id);
173
+ if (!check)
174
+ return [];
175
+ const state = check.state === 'pass' || check.state === 'partial' || check.state === 'skipped' ? check.state : 'unknown';
176
+ return [`${id}_${state}`];
177
+ });
178
+ const omittedCheckCount = report.checks.length - checkCodes.length;
179
+ return Object.freeze({
180
+ schema: DOCTOR_SUMMARY_SCHEMA,
181
+ version: DOCTOR_SUMMARY_VERSION,
182
+ cliVersion: diagnosticVersion(report.cliVersion),
183
+ nodeVersion: diagnosticVersion(nodeVersion),
184
+ state: report.state === 'pass' && omittedCheckCount === 0 ? 'pass' : 'partial',
185
+ networkRequested: report.networkRequested === true,
186
+ checkCodes: Object.freeze(checkCodes),
187
+ omittedCheckCount,
188
+ declaredFormatIdentifiers: Object.freeze([...new Set(CLI_COMMAND_REGISTRY.flatMap(command => command.documentation.supportedSchemaIdentifiers))].sort()),
189
+ formatScope: 'Public command declarations only; not an installed-package or input-compatibility test.',
190
+ omissions: Object.freeze([
191
+ 'Targets, URLs, queries and fragments',
192
+ 'File paths, folder names and user names',
193
+ 'Launch tokens, cookies, credentials and headers',
194
+ 'Input files, response content and free-form diagnostic details',
195
+ 'Timestamps, platform details and version prerelease/build metadata',
196
+ ]),
197
+ });
198
+ }
165
199
  function formatDoctorReport(report) {
166
200
  const lines = [
167
201
  'Runtime:',
@@ -175,4 +209,4 @@ function formatDoctorReport(report) {
175
209
  }
176
210
  return `${lines.join('\n')}\n`;
177
211
  }
178
- export { buildDoctorReport, formatDoctorReport, };
212
+ export { buildDoctorReport, buildDoctorSummary, formatDoctorReport, };
@@ -40,6 +40,7 @@ function formatLookupEvidenceHtml(document, options = {}) {
40
40
  <style>
41
41
  :root{color-scheme:dark;--bg:#07110d;--panel:#0d1b15;--panel2:#10241b;--line:#254837;--text:#d8f5e4;--muted:#91b7a0;--accent:#6dff9d;--shadow:rgba(0,0,0,.28)}
42
42
  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:min(1120px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{border:1px solid var(--line);background:linear-gradient(145deg,var(--panel2),var(--panel));padding:28px;box-shadow:0 18px 50px var(--shadow)}h1,h2,h3{line-height:1.2;margin:0;color:var(--accent)}h1{font-size:clamp(1.5rem,4vw,2.35rem);overflow-wrap:anywhere}h2{font-size:1.22rem;margin-bottom:16px}h3{font-size:1rem;margin-bottom:12px}.eyebrow{margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}.notice{margin:18px 0 0;color:var(--muted);max-width:85ch}.block{margin-top:24px;border:1px solid var(--line);background:var(--panel);padding:22px}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px;margin:0}.fields div{display:grid;grid-template-columns:minmax(130px,.8fr) minmax(0,1.6fr);gap:12px;border-top:1px solid var(--line);padding:9px 0}.fields div:first-child,.fields div:nth-child(2){border-top:0}.fields dt{color:var(--muted)}.fields dd{margin:0;overflow-wrap:anywhere}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:16px}.card{border:1px solid var(--line);background:var(--panel2);padding:18px}.card .fields{display:block}.card .fields div{grid-template-columns:minmax(120px,.8fr) minmax(0,1.4fr)}.card .fields div:nth-child(2){border-top:1px solid var(--line)}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;min-width:720px}caption{text-align:left;color:var(--muted);padding:0 0 10px}th,td{border:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:var(--panel2);color:var(--accent)}tbody th{color:var(--text)}.status{white-space:nowrap;color:var(--accent)}.empty,.omission{color:var(--muted)}ul{margin:0;padding-left:22px}li+li{margin-top:8px}footer{margin-top:24px;color:var(--muted);font-size:.88rem;text-align:center}
43
+ footer{overflow-wrap:anywhere}
43
44
  @media(max-width:700px){main{width:min(100% - 20px,1120px);padding-top:16px}header,.block{padding:17px}.fields{display:block}.fields div,.card .fields div{grid-template-columns:1fr;gap:2px}.fields div:nth-child(2){border-top:1px solid var(--line)}}
44
45
  @media print{:root{color-scheme:light;--bg:#fff;--panel:#fff;--panel2:#f5f7f5;--line:#b9c5bd;--text:#17231b;--muted:#46564b;--accent:#155c2f;--shadow:transparent}body{font:10pt/1.42 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:100%;padding:0}header,.block,.card{box-shadow:none;break-inside:avoid}.block{margin-top:12px}}
45
46
  </style>
@@ -55,6 +56,8 @@ function formatLookupEvidenceHtml(document, options = {}) {
55
56
  <section class="block"><h2>Query</h2>${renderFields(report.query)}</section>
56
57
  <section class="block"><h2>Assessment</h2>${renderFields(report.assessment)}</section>
57
58
  <section class="block"><h2>Registry sources</h2>${renderGroups(report.registryGroups)}</section>
59
+ ${report.registrarStanding.length ? `<section class="block"><h2>Registrar standing</h2>${renderFields(report.registrarStanding)}</section>` : ''}
60
+ ${report.registryInterpretation.length ? `<section class="block"><h2>Registry interpretation</h2>${renderFields(report.registryInterpretation)}</section>` : ''}
58
61
  <section class="block"><h2>Registry-source comparison</h2>${renderFields(report.comparison.health)}${renderComparison(report.comparison)}</section>
59
62
  ${report.registrarComparison.fields.length ? `<section class="block"><h2>Registry / registrar RDAP comparison</h2>${renderFields(report.registrarComparison.health)}${renderRegistrarComparison(report.registrarComparison)}</section>` : ''}
60
63
  <section class="block"><h2>Network evidence</h2>${renderGroups(report.networkGroups)}</section>
@@ -69,7 +69,14 @@ function versionedResult(result, schema, version, generatedAt, requestedField, r
69
69
  return { ...result, schema, version, generatedAt, [requestedField]: requestedValue };
70
70
  }
71
71
  function buildCliPostureDocument(requestedDomain, report, generatedAt = new Date().toISOString()) {
72
- return versionedResult(report, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
72
+ const checks = report.checks;
73
+ const retained = Array.isArray(checks) ? { ...report, checks: checks.map(check => {
74
+ if (!check || typeof check !== 'object' || Array.isArray(check))
75
+ return check;
76
+ const { dmarcPolicyContext: _transient, ...fields } = check;
77
+ return fields;
78
+ }) } : report;
79
+ return versionedResult(retained, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
73
80
  }
74
81
  function buildCliHttpDocument(requestedDomain, result, generatedAt = new Date().toISOString()) {
75
82
  return versionedResult(result, CLI_HTTP_SCHEMA, CLI_HTTP_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
@@ -1,5 +1,7 @@
1
1
  import { registryAccessProfileLabel } from "../registry-access.mjs";
2
+ import { isSupportedRegistryInsightsVersion } from "../../packages/contracts/registry-insights.mjs";
2
3
  import { dnsAddressFamilyEvidence } from "../../packages/evidence/dns-query-outcome.mjs";
4
+ import { lookupCollectionReceiptReview } from "../../packages/evidence/lookup-collection-review.mjs";
3
5
  import { appendTechnologyLines } from "./terminal-technology.mjs";
4
6
  import { appendDeliveryMetadataLines, appendPublicationMetadataLines } from "./terminal-metadata.mjs";
5
7
  import { securityPostureReview, summarizeSecurityPostureReview } from "../../lib/website-security-posture.mjs";
@@ -252,6 +254,14 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
252
254
  `RDAP ${titleCase(rdapDiagnostics.status)}`,
253
255
  `WHOIS ${titleCase(whoisDiagnostics.status)}`,
254
256
  ];
257
+ if (detail !== 'summary') {
258
+ for (const [source, status] of [['rdap', rdapDiagnostics.status], ['whois', whoisDiagnostics.status]]) {
259
+ const sourceDetail = terminalRecord(document[source]).detail;
260
+ if (['unsupported', 'skipped', 'disabled'].includes(String(status)) && typeof sourceDetail === 'string' && sourceDetail) {
261
+ registrationLines.push(`${source.toUpperCase()} detail ${boundedTerminalComponent(sourceDetail, 300)}`);
262
+ }
263
+ }
264
+ }
255
265
  if (detail !== 'summary' && rdapDiagnostics.endpoint) {
256
266
  registrationLines.splice(1, 0, `RDAP source ${safeTerminalValue(rdapDiagnostics.endpoint)}`);
257
267
  }
@@ -267,7 +277,7 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
267
277
  appendNetworkRegistrationLines(registrationLines, terminalRecord(rdap.parsed), document.type, detail);
268
278
  }
269
279
  const registryInsights = terminalRecord(document.registryInsights);
270
- if (detail !== 'summary' && registryInsights.version === 1) {
280
+ if (detail !== 'summary' && isSupportedRegistryInsightsVersion(registryInsights.version)) {
271
281
  const lifecycle = terminalRecord(registryInsights.lifecycle);
272
282
  const disclosure = terminalRecord(registryInsights.contactDisclosure);
273
283
  const registryDisclosure = terminalRecord(disclosure.registryRdap);
@@ -712,6 +722,19 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
712
722
  const collectionLines = [];
713
723
  if (detail === 'verbose') {
714
724
  collectionLines.push(`Generated ${safeTerminalValue(document.generatedAt, 'unknown')}`);
725
+ const receipt = lookupCollectionReceiptReview(document.collectionReceipt);
726
+ if (receipt) {
727
+ collectionLines.push(`Original plan ${receipt.selection}`);
728
+ for (const choice of receipt.choices)
729
+ collectionLines.push(`${choice.label}: ${choice.selected ? 'selected' : 'not selected'}`);
730
+ for (const source of receipt.sources) {
731
+ collectionLines.push(`${source.label}: ${source.summary}; ${source.outcome}`);
732
+ collectionLines.push(` ${source.explanation}`);
733
+ }
734
+ collectionLines.push('Collector start is local execution provenance, not proof of network delivery or complete evidence.');
735
+ }
736
+ else
737
+ collectionLines.push('Original plan Not retained; original optional choices and collector starts are unknown.');
715
738
  const timing = terminalRecord(document.timing);
716
739
  if (Number(timing.totalMs) >= 0)
717
740
  collectionLines.push(`Total time ${safeTerminalValue(timing.totalMs)} ms`);
@@ -65,7 +65,7 @@ export const HISTORY_COMMAND_DEFINITIONS = Object.freeze({
65
65
  reference: {
66
66
  description: 'Convert one saved lookup into a versioned evidence report.',
67
67
  example: 'whoisleuth export lookup.json --markdown',
68
- boundary: `Saved Lookup versions 1 and 2 are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schemas ${PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
68
+ boundary: `Supported saved Lookups are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schemas ${PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
69
69
  },
70
70
  collection: { scope: 'Reads one saved Lookup and writes one bounded report.' },
71
71
  summary: 'Convert a lookup to an evidence report',
@@ -75,6 +75,7 @@ export function formatWorkflowRecipeCatalogue(catalogue) {
75
75
  `${recipe.id} — ${recipe.label}`,
76
76
  ` Subject: ${recipe.subjectRequirement.replaceAll('_', ' ')}`,
77
77
  ` Workflow run: ${recipe.runnableByWorkflowRun ? 'supported' : 'plan and explanation only'}`,
78
+ ...formatWorkflowEffort(recipe.id, recipe.steps).map((line) => ` ${line}`),
78
79
  ` ${recipe.objective}`,
79
80
  ...recipe.steps.map((item, index) => ` ${index + 1}. ${item.label} [${item.mode}; ${item.approval.replaceAll('_', ' ')}]`),
80
81
  ...recipe.limitations.map((item) => ` Limitation: ${item}`),
@@ -84,6 +85,19 @@ export function formatWorkflowRecipeCatalogue(catalogue) {
84
85
  '',
85
86
  ].join('\n');
86
87
  }
88
+ function formatWorkflowEffort(recipeId, steps) {
89
+ const counts = { network: 0, offline: 0 };
90
+ for (const step of steps)
91
+ counts[step.mode] += 1;
92
+ const reuse = workflowStandardInputs(recipeId).length;
93
+ return [
94
+ `Work planned: ${counts.network} networked step${counts.network === 1 ? '' : 's'}; ${counts.offline} offline step${counts.offline === 1 ? '' : 's'}.`,
95
+ reuse
96
+ ? `Evidence reuse: ${reuse} link${reuse === 1 ? '' : 's'} from earlier outputs to later steps.`
97
+ : 'Evidence reuse: no earlier-output links are declared.',
98
+ 'Counts describe steps and reuse links, not requests. Saved files and cache state are not checked.',
99
+ ];
100
+ }
87
101
  function validatedSteps(steps) {
88
102
  for (const step of steps) {
89
103
  const invocationEffect = cliInvocationNetworkEffect(step.command, step.arguments);
@@ -147,6 +161,7 @@ export function formatInvestigationPlan(plan) {
147
161
  `Investigation plan: ${plan.recipe.label}`,
148
162
  `Subject ${plan.subject}`,
149
163
  `Mode ${plan.execution}`,
164
+ ...formatWorkflowEffort(plan.recipe.id, plan.steps),
150
165
  '',
151
166
  ...plan.steps.flatMap((item, index) => [
152
167
  `${index + 1}. ${item.label}`,
@@ -214,23 +214,29 @@ export function buildCliLookupBrief(input, generatedAt = new Date().toISOString(
214
214
  });
215
215
  }
216
216
  export function formatCliLookupBrief(document) {
217
+ const sourceFacts = document.sourceHealth.flatMap((source) => {
218
+ const facts = document.facts.filter((item) => item.source.id === source.id);
219
+ return [
220
+ ` ${safeTerminalValue(source.label)} [${safeTerminalValue(source.state)}; ${source.observedAt ? `observed ${safeTerminalValue(source.observedAt)}` : 'source time not recorded'}]`,
221
+ ...(facts.length
222
+ ? facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)}`)
223
+ : [' No fact selected for this brief.']),
224
+ ];
225
+ });
217
226
  return [
218
227
  `Lookup brief: ${safeTerminalValue(document.target)}`,
219
228
  `Observed ${safeTerminalValue(document.observedAt)}`,
220
229
  `Mode ${safeTerminalValue(document.mode)}`,
221
230
  '',
222
231
  'Source-attributed facts',
223
- ...(document.facts.length ? document.facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)} (${safeTerminalValue(item.source.label)}; ${safeTerminalValue(item.source.state)}; ${item.source.observedAt ? `observed ${safeTerminalValue(item.source.observedAt)}` : 'source time not recorded'})`) : [' No bounded fact was available.']),
232
+ ...sourceFacts,
224
233
  '',
225
234
  'Contradictions',
226
235
  ...(document.contradictions.length ? document.contradictions.flatMap((item) => [
227
236
  ` ${safeTerminalValue(item.field)}: ${safeTerminalValue(item.detail)}`,
228
- ...item.observations.map((observation) => ` ${safeTerminalValue(observation.source.label)}: ${safeTerminalValue(observation.value)} (${safeTerminalValue(observation.state)}; ${observation.source.observedAt ? `observed ${safeTerminalValue(observation.source.observedAt)}` : 'source time not recorded'})`),
237
+ ...item.observations.map((observation) => ` ${safeTerminalValue(observation.source.label)}: ${safeTerminalValue(observation.value)} (value state: ${safeTerminalValue(observation.state)}; source state: ${safeTerminalValue(observation.source.state)}; ${observation.source.observedAt ? `observed ${safeTerminalValue(observation.source.observedAt)}` : 'source time not recorded'})`),
229
238
  ]) : [' No conflicting publication field was retained.']),
230
239
  '',
231
- 'Unknown or incomplete',
232
- ...(document.unknowns.length ? document.unknowns.map((item) => ` ${safeTerminalValue(item)}`) : [' No incomplete source state was identified.']),
233
- '',
234
240
  'Recommended manual actions',
235
241
  ...document.actionPlan.flatMap((item, index) => [
236
242
  ` ${index + 1}. ${safeTerminalValue(item.action)}`,
@@ -67,25 +67,32 @@ async function runLookupCommand(args, dependencies, context) {
67
67
  return EXIT_CODES.SUCCESS;
68
68
  }
69
69
  let documentGeneratedAt = '';
70
- const buildDocument = (result) => {
70
+ const buildDocument = ({ result, collectionReceipt }) => {
71
71
  documentGeneratedAt = context.now();
72
72
  return buildCliLookupDocument(query, classified, result, documentGeneratedAt, args.deep ? 'deep' : 'fast', {
73
+ collectionReceipt,
73
74
  ...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
74
75
  ...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
75
76
  });
76
77
  };
77
- const collect = (signal, onSourceSettled) => abortable(() => executeLookup(classified, {
78
- fast: !args.deep,
79
- compact: false,
80
- ...(signal ? { signal } : {}),
81
- ...(args.deep ? {
82
- ...(selectedUrl ? { selectedUrl } : {}),
83
- onSourceSettled: settlement => {
84
- onSourceSettled(settlement);
85
- eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
86
- },
87
- } : {}),
88
- }), signal);
78
+ const collect = (signal, onSourceSettled) => abortable(async () => {
79
+ let collectionReceipt;
80
+ const result = await executeLookup(classified, {
81
+ fast: !args.deep,
82
+ compact: false,
83
+ onCollectionReceipt: value => { collectionReceipt = value; },
84
+ ...(signal ? { signal } : {}),
85
+ ...(args.deep ? {
86
+ ...(selectedUrl ? { selectedUrl } : {}),
87
+ onSourceSettled: settlement => {
88
+ onSourceSettled(settlement);
89
+ eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
90
+ },
91
+ } : {}),
92
+ });
93
+ signal?.throwIfAborted();
94
+ return { result, collectionReceipt };
95
+ }, signal);
89
96
  let document;
90
97
  if (args.browse === true) {
91
98
  const browse = dependencies.browseLookupOperation || browseLookupOperation;