@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
package/cli/input.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
2
+ import { decodeBoundedUtf8, readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
3
3
  import { CliUsageError } from "./errors.mjs";
4
4
  function abortError(signal) {
5
5
  return signal.reason instanceof Error
@@ -49,7 +49,12 @@ export async function readCliTextInput(source, stdin, options) {
49
49
  }
50
50
  if (options.signal?.aborted)
51
51
  throw abortError(options.signal);
52
- return Buffer.concat(chunks, bytes).toString('utf8');
52
+ try {
53
+ return decodeBoundedUtf8(Buffer.concat(chunks, bytes), options.label);
54
+ }
55
+ catch (cause) {
56
+ throw new CliUsageError(cause instanceof Error ? cause.message : `${options.label} must contain valid UTF-8 text.`);
57
+ }
53
58
  }
54
59
  finally {
55
60
  options.signal?.removeEventListener('abort', onAbort);
@@ -0,0 +1,226 @@
1
+ import { classifyQuery, isDirectLookupTarget } from "../lib/classify.mjs";
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ import { boundedCliErrorMessage, CliUsageError, hasUnsafeCliText } from "./errors.mjs";
4
+ import { INVESTIGATION_PLAN_RECIPE_LABELS, INVESTIGATION_PLAN_RECIPES, } from "./investigation-plan.mjs";
5
+ import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
6
+ import { canBrowseLookup, } from "./lookup-browser.mjs";
7
+ const MAX_INTERACTIVE_ANSWER_SCALARS = 1_024;
8
+ const MAX_INTERACTIVE_ANSWER_BYTES = 4_096;
9
+ function canLaunchInteractiveCli(input, output, environment = process.env) {
10
+ return input?.isTTY === true
11
+ && canBrowseLookup(input, output, environment);
12
+ }
13
+ function boundedInteractiveAnswer(value) {
14
+ const supplied = typeof value === 'string' ? value : '';
15
+ if (Buffer.byteLength(supplied, 'utf8') > MAX_INTERACTIVE_ANSWER_BYTES
16
+ || Array.from(supplied).length > MAX_INTERACTIVE_ANSWER_SCALARS
17
+ || hasUnsafeCliText(supplied)) {
18
+ throw new CliUsageError('Interactive input must be bounded text without control characters.');
19
+ }
20
+ return supplied.trim();
21
+ }
22
+ function abortError() {
23
+ return new DOMException('Aborted', 'AbortError');
24
+ }
25
+ function readBoundedInteractiveLine(prompt, options) {
26
+ const { input, output, signal } = options;
27
+ if (signal?.aborted)
28
+ return Promise.reject(signal.reason || abortError());
29
+ const previousRaw = input.isRaw === true;
30
+ const wasPaused = input.isPaused?.() === true;
31
+ let receivedBytes = 0;
32
+ let receivedScalars = 0;
33
+ let settled = false;
34
+ let characters = [];
35
+ const decoder = new StringDecoder('utf8');
36
+ return new Promise((resolve, reject) => {
37
+ const cleanup = () => {
38
+ let failure = null;
39
+ const attempt = (operation) => {
40
+ try {
41
+ operation();
42
+ }
43
+ catch (error) {
44
+ failure ||= error instanceof Error ? error : new Error('Interactive terminal cleanup failed.');
45
+ }
46
+ };
47
+ attempt(() => input.off?.('data', onData));
48
+ attempt(() => input.off?.('end', onEnd));
49
+ attempt(() => signal?.removeEventListener('abort', onAbort));
50
+ attempt(() => input.setRawMode?.(previousRaw));
51
+ if (wasPaused)
52
+ attempt(() => input.pause?.());
53
+ return failure;
54
+ };
55
+ const finish = (value, error) => {
56
+ if (settled)
57
+ return;
58
+ settled = true;
59
+ const cleanupFailure = cleanup();
60
+ characters = [];
61
+ if (error && cleanupFailure) {
62
+ reject(new AggregateError([error, cleanupFailure], `${error.message}; terminal cleanup also failed: ${cleanupFailure.message}`));
63
+ }
64
+ else if (error || cleanupFailure)
65
+ reject(error || cleanupFailure);
66
+ else
67
+ resolve(value || '');
68
+ };
69
+ const rejectUsage = () => finish(undefined, new CliUsageError('Interactive input must be bounded text without control characters.'));
70
+ const onData = (chunk) => {
71
+ if (settled)
72
+ return;
73
+ let rawChunk;
74
+ if (Buffer.isBuffer(chunk)) {
75
+ if (chunk.length > MAX_INTERACTIVE_ANSWER_BYTES - receivedBytes) {
76
+ rejectUsage();
77
+ return;
78
+ }
79
+ rawChunk = chunk;
80
+ }
81
+ else {
82
+ const supplied = String(chunk ?? '');
83
+ if (supplied.length > MAX_INTERACTIVE_ANSWER_BYTES - receivedBytes
84
+ || Buffer.byteLength(supplied, 'utf8') > MAX_INTERACTIVE_ANSWER_BYTES - receivedBytes) {
85
+ rejectUsage();
86
+ return;
87
+ }
88
+ rawChunk = Buffer.from(supplied, 'utf8');
89
+ }
90
+ receivedBytes += rawChunk.length;
91
+ const value = decoder.write(rawChunk);
92
+ receivedScalars += Array.from(value).length;
93
+ if (receivedBytes > MAX_INTERACTIVE_ANSWER_BYTES
94
+ || receivedScalars > MAX_INTERACTIVE_ANSWER_SCALARS
95
+ || value.includes('\ufffd')) {
96
+ rejectUsage();
97
+ return;
98
+ }
99
+ for (const character of value) {
100
+ if (character === '\r' || character === '\n') {
101
+ try {
102
+ output.write('\n');
103
+ }
104
+ catch (error) {
105
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal output failed.'));
106
+ return;
107
+ }
108
+ finish(characters.join(''));
109
+ return;
110
+ }
111
+ if (character === '\u0003' || character === '\u0004' || character === '\u001b') {
112
+ finish(undefined, abortError());
113
+ return;
114
+ }
115
+ if (character === '\u0008' || character === '\u007f') {
116
+ if (characters.length) {
117
+ characters.pop();
118
+ try {
119
+ output.write('\b \b');
120
+ }
121
+ catch (error) {
122
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal output failed.'));
123
+ return;
124
+ }
125
+ }
126
+ continue;
127
+ }
128
+ if (hasUnsafeCliText(character)) {
129
+ rejectUsage();
130
+ return;
131
+ }
132
+ characters.push(character);
133
+ try {
134
+ output.write(character);
135
+ }
136
+ catch (error) {
137
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal output failed.'));
138
+ return;
139
+ }
140
+ }
141
+ };
142
+ const onEnd = () => {
143
+ const remainder = decoder.end();
144
+ if (remainder)
145
+ rejectUsage();
146
+ else
147
+ finish(undefined, abortError());
148
+ };
149
+ const onAbort = () => finish(undefined, signal?.reason instanceof Error ? signal.reason : abortError());
150
+ try {
151
+ output.write(prompt);
152
+ input.on?.('data', onData);
153
+ input.on?.('end', onEnd);
154
+ signal?.addEventListener('abort', onAbort, { once: true });
155
+ if (signal?.aborted) {
156
+ onAbort();
157
+ return;
158
+ }
159
+ input.setRawMode?.(true);
160
+ if (settled)
161
+ return;
162
+ input.resume?.();
163
+ }
164
+ catch (error) {
165
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal input failed.'));
166
+ }
167
+ });
168
+ }
169
+ async function launchInteractiveCli(options) {
170
+ const environment = options.environment || process.env;
171
+ if (!canLaunchInteractiveCli(options.input, options.output, environment)) {
172
+ throw new CliUsageError('Interactive launch requires terminal input and output.');
173
+ }
174
+ if (options.signal?.aborted)
175
+ throw options.signal.reason || new DOMException('Aborted', 'AbortError');
176
+ const question = options.question || ((prompt) => readBoundedInteractiveLine(prompt, options));
177
+ const ask = async (prompt) => boundedInteractiveAnswer(await question(prompt));
178
+ const write = (value) => options.output.write(value);
179
+ write([
180
+ 'WHOISleuth interactive launch',
181
+ '',
182
+ ' 1 Fast Lookup',
183
+ ' 2 Deep Lookup',
184
+ ' 3 Build an investigation workflow plan (offline)',
185
+ ' 4 List commands',
186
+ ' q Exit',
187
+ '',
188
+ ].join('\n'));
189
+ const selection = (await ask('Select: ')).toLowerCase();
190
+ if (selection === 'q' || selection === 'quit' || selection === 'exit' || selection === '')
191
+ return null;
192
+ if (selection === '4')
193
+ return ['commands'];
194
+ if (selection === '3') {
195
+ write(`\n${INVESTIGATION_PLAN_RECIPES.map((recipe, index) => ` ${index + 1} ${INVESTIGATION_PLAN_RECIPE_LABELS[recipe]}`).join('\n')}\n\n`);
196
+ const recipeSelection = await ask('Workflow: ');
197
+ const recipe = INVESTIGATION_PLAN_RECIPES[Number(recipeSelection) - 1];
198
+ if (!recipe)
199
+ throw new CliUsageError('Choose one listed workflow number.');
200
+ const subject = await ask('Domain or brand subject: ');
201
+ if (!subject)
202
+ throw new CliUsageError('A workflow plan requires one domain or brand subject.');
203
+ return ['workflow-plan', recipe, subject];
204
+ }
205
+ if (selection !== '1' && selection !== '2')
206
+ throw new CliUsageError('Choose 1, 2, 3, 4, or q.');
207
+ const query = await ask('Domain, IP address, or ASN: ');
208
+ if (!isDirectLookupTarget(query)) {
209
+ throw new CliUsageError('Interactive Lookup requires an unambiguous domain with a recognised public suffix, public IP, or ASN.');
210
+ }
211
+ const deep = selection === '2';
212
+ let classified;
213
+ try {
214
+ classified = classifyQuery(query);
215
+ }
216
+ catch (error) {
217
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid Lookup target'));
218
+ }
219
+ const plan = buildCliLookupPlan(query, classified, deep);
220
+ write(`\n${formatCliLookupPlan(plan)}\n`);
221
+ const confirmed = (await ask('Start this collection and open the evidence browser? [y/N] ')).toLowerCase();
222
+ if (confirmed !== 'y' && confirmed !== 'yes')
223
+ return null;
224
+ return ['lookup', query, deep ? '--deep' : '--fast', '--browse'];
225
+ }
226
+ export { MAX_INTERACTIVE_ANSWER_BYTES, MAX_INTERACTIVE_ANSWER_SCALARS, boundedInteractiveAnswer, canLaunchInteractiveCli, launchInteractiveCli, readBoundedInteractiveLine, };
@@ -1,8 +1,9 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { INTERCHANGE_ARTIFACT_CONTRACTS, } from "../lib/interchange-fidelity-registry.mjs";
3
3
  import { offlineArtifactSatisfiesAssurance, verifyOfflineArtifact, } from "./artifact-verify.mjs";
4
- import { mergeBrandProfiles } from "../frontend/src/lib/analysis/brand-profile-model.js";
4
+ import { mergeBrandProfiles, } from "../packages/workspace/brand-profile-model.mjs";
5
5
  import { parseBoundedJsonObject } from "./bounded-json.mjs";
6
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
6
7
  export const INTERCHANGE_FIDELITY_REPORT_SCHEMA = 'whoisleuth.interchange-fidelity-report';
7
8
  export const INTERCHANGE_FIDELITY_REPORT_VERSION = 2;
8
9
  export const MAX_INTERCHANGE_REPORT_BYTES = 15 * 1024 * 1024;
@@ -45,25 +46,20 @@ function identify(value) {
45
46
  return null;
46
47
  }
47
48
  function timestamp(value) {
48
- if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
49
+ const normalized = normalizeExplicitIsoTimestamp(value);
50
+ if (!normalized)
49
51
  throw new TypeError('Interchange report time is invalid.');
50
- }
51
- const parsed = Date.parse(value);
52
- if (!Number.isFinite(parsed))
53
- throw new TypeError('Interchange report time is invalid.');
54
- return new Date(parsed).toISOString();
52
+ return normalized;
55
53
  }
56
- function validateBrandProfileExport(value) {
54
+ function validateBrandProfileExport(value, version) {
57
55
  const profiles = value.profiles;
58
56
  if (!Array.isArray(profiles) || profiles.length > 100) {
59
57
  throw new TypeError('Brand Profile export does not contain a bounded profiles collection.');
60
58
  }
61
- if (typeof value.exportedAt !== 'string' || value.exportedAt.length > 64 || !Number.isFinite(Date.parse(value.exportedAt))) {
62
- throw new TypeError('Brand Profile export time is invalid.');
63
- }
59
+ const exportedAt = timestamp(value.exportedAt);
64
60
  let nextId = 0;
65
61
  const result = mergeBrandProfiles([], value, {
66
- nowIso: new Date(Date.parse(value.exportedAt)).toISOString(),
62
+ nowIso: exportedAt,
67
63
  makeId: () => `interchange-profile-${++nextId}`,
68
64
  });
69
65
  return Object.freeze({ accepted: result.profiles.length, skipped: result.skipped });
@@ -116,7 +112,9 @@ export async function buildInterchangeFidelityReport(raw, options = {}) {
116
112
  if (supported && contract.fidelity !== 'unsupported') {
117
113
  try {
118
114
  if (contract.id === 'brand_profiles') {
119
- const validation = validateBrandProfileExport(value);
115
+ if (version === null)
116
+ throw new TypeError('Brand Profile export version is invalid.');
117
+ const validation = validateBrandProfileExport(value, version);
120
118
  recordCount = validation.accepted;
121
119
  acceptedRecordCount = validation.accepted;
122
120
  skippedRecordCount = validation.skipped;
@@ -128,12 +126,14 @@ export async function buildInterchangeFidelityReport(raw, options = {}) {
128
126
  verificationState = verification.state;
129
127
  assuranceSatisfied = contract.requiredAssurance === 'whole_integrity'
130
128
  ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
131
- : contract.requiredAssurance === 'structure'
132
- ? offlineArtifactSatisfiesAssurance(verification, 'structure')
133
- : contract.requiredAssurance === 'authenticated_whole_integrity'
134
- ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
135
- && verification.checks.authenticatedEncryption === 'verified'
136
- : false;
129
+ : contract.requiredAssurance === 'applicable_integrity'
130
+ ? offlineArtifactSatisfiesAssurance(verification, 'applicable_integrity')
131
+ : contract.requiredAssurance === 'structure'
132
+ ? offlineArtifactSatisfiesAssurance(verification, 'structure')
133
+ : contract.requiredAssurance === 'authenticated_whole_integrity'
134
+ ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
135
+ && verification.checks.authenticatedEncryption === 'verified'
136
+ : false;
137
137
  recordCount = verification.summary.recordCount;
138
138
  sectionCount = verification.summary.sectionCount;
139
139
  const unsupportedSections = verification.summary.unsupportedSectionCount ?? 0;
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
- import { canonicalArtifactJsonV2, SORTED_JSON_V2, sha256ArtifactDigestV2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
3
+ import { canonicalArtifactJsonV2, SORTED_JSON_V2, sha256ArtifactDigestV2, } from "../packages/evidence/artifact-integrity.mjs";
4
4
  import { requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
5
5
  import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
6
6
  import { parseBoundedJsonObject } from "./bounded-json.mjs";
@@ -1,17 +1,19 @@
1
1
  import { domainToASCII } from 'node:url';
2
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
3
+ import { INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES, cliInvocationNetworkEffect, } from "./command-reference.mjs";
2
4
  export const CLI_INVESTIGATION_PLAN_SCHEMA = 'whoisleuth.cli.investigation-plan';
3
5
  export const CLI_INVESTIGATION_PLAN_VERSION = 1;
4
- export const INVESTIGATION_PLAN_RECIPES = [
5
- 'domain-triage',
6
- 'lookalike-review',
7
- 'owned-domain-review',
8
- 'historical-comparison',
9
- ];
6
+ export const CLI_WORKFLOW_RECIPE_CATALOGUE_SCHEMA = 'whoisleuth.cli.workflow-recipe-catalogue';
7
+ export const CLI_WORKFLOW_RECIPE_CATALOGUE_VERSION = 1;
8
+ export { INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES };
10
9
  const RECIPES = Object.freeze({
11
10
  'domain-triage': Object.freeze({
11
+ id: 'domain-triage',
12
12
  label: 'New domain triage',
13
- requiresDomain: true,
14
- objective: 'Collect and preserve a source-aware registration, DNS, HTTP, TLS, page, and network-context review.',
13
+ subjectRequirement: 'domain',
14
+ objective: 'Collect and preserve separately attributed registration, DNS, HTTP, TLS, page, and network-context evidence.',
15
+ runnable: true,
16
+ limitations: Object.freeze(['Collection remains analyst-triggered and source limitations remain explicit.']),
15
17
  steps: (domain) => Object.freeze([
16
18
  step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
17
19
  step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
@@ -19,9 +21,12 @@ const RECIPES = Object.freeze({
19
21
  ]),
20
22
  }),
21
23
  'lookalike-review': Object.freeze({
24
+ id: 'lookalike-review',
22
25
  label: 'Lookalike candidate review',
23
- requiresDomain: false,
26
+ subjectRequirement: 'brand_or_domain',
24
27
  objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
28
+ runnable: true,
29
+ limitations: Object.freeze(['Candidate generation does not establish registration, control, intent, or maliciousness.']),
25
30
  steps: (subject) => Object.freeze([
26
31
  step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
27
32
  step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
@@ -29,9 +34,12 @@ const RECIPES = Object.freeze({
29
34
  ]),
30
35
  }),
31
36
  'owned-domain-review': Object.freeze({
37
+ id: 'owned-domain-review',
32
38
  label: 'Owned domain posture review',
33
- requiresDomain: true,
39
+ subjectRequirement: 'domain',
34
40
  objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
41
+ runnable: true,
42
+ limitations: Object.freeze(['Use only for a domain the analyst owns or is authorised to review.']),
35
43
  steps: (domain) => Object.freeze([
36
44
  step('posture', 'Collect bounded DNS posture', 'posture', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.posture', 'Review mail profile and delegation evidence before interpreting missing records.'),
37
45
  step('lookup', 'Collect supporting Deep evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain separately attributed registration, DNS, TLS, and page observations.'),
@@ -39,17 +47,160 @@ const RECIPES = Object.freeze({
39
47
  ]),
40
48
  }),
41
49
  'historical-comparison': Object.freeze({
50
+ id: 'historical-comparison',
42
51
  label: 'Historical observation comparison',
43
- requiresDomain: true,
52
+ subjectRequirement: 'domain',
44
53
  objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
54
+ runnable: true,
55
+ limitations: Object.freeze(['A later observation does not retroactively refresh retained evidence.']),
45
56
  steps: (domain) => Object.freeze([
46
57
  step('current', 'Collect the current lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'A current request does not refresh or validate older provider-reported history.'),
47
58
  step('diff', 'Compare two selected observations', 'diff', ['<previous.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Equal, different, conflicting, and unavailable evidence remain separate.'),
48
59
  step('timeline', 'Build a bounded local timeline', 'timeline', ['<oldest.json>', '<newer.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-timeline', 'Choose two to twenty same-domain files in chronological scope.'),
49
60
  ]),
50
61
  }),
62
+ 'campaign-review': Object.freeze({
63
+ id: 'campaign-review',
64
+ label: 'Campaign candidate review',
65
+ subjectRequirement: 'brand_or_domain',
66
+ objective: 'Prepare a bounded candidate set, collect a deliberately selected queue, and review retained evidence without asserting campaign attribution.',
67
+ runnable: false,
68
+ limitations: Object.freeze(['Grouping candidates is analyst triage and does not prove common ownership, control, infrastructure, or intent.']),
69
+ steps: (subject) => Object.freeze([
70
+ step('prepare', 'Prepare candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and bounded omissions before selecting a collection scope.'),
71
+ step('collect', 'Collect the selected candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Treat partial and inconclusive authority results as explicit outcomes.'),
72
+ step('review', 'Review selected retained evidence', 'review-evidence', ['<selected-evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.offline-evidence-review', 'Keep source observations separate and record any campaign grouping as analyst-authored.'),
73
+ ]),
74
+ }),
75
+ 'certificate-anomaly': Object.freeze({
76
+ id: 'certificate-anomaly',
77
+ label: 'Certificate anomaly review',
78
+ subjectRequirement: 'domain',
79
+ objective: 'Review bounded certificate observations alongside current source-qualified domain evidence without treating issuance as proof of control or intent.',
80
+ runnable: false,
81
+ limitations: Object.freeze(['Certificate observations are separately attributed and do not establish current service control.']),
82
+ steps: (domain) => Object.freeze([
83
+ step('search', 'Collect bounded certificate observations', 'ct-search', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.ct-search', 'Review source availability, truncation, and observation timing.'),
84
+ step('intake', 'Normalise the selected observations', 'ct-intake', ['<certificate-events.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.ct-event-batch', 'Only selected saved observations enter the offline intake.'),
85
+ step('corroborate', 'Collect supporting domain evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Compare evidence families without collapsing certificate and registration identities.'),
86
+ ]),
87
+ }),
88
+ 'registry-disagreement': Object.freeze({
89
+ id: 'registry-disagreement',
90
+ label: 'Registry disagreement review',
91
+ subjectRequirement: 'domain',
92
+ objective: 'Collect separately attributed registration evidence and review conflicting publications without selecting an arbitrary source as truth.',
93
+ runnable: false,
94
+ limitations: Object.freeze(['Only authority-aware registration evidence may decide availability; disagreement remains explicit.']),
95
+ steps: (domain) => Object.freeze([
96
+ step('collect', 'Collect source-qualified registration evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain RDAP, registrar RDAP, WHOIS, and authority states separately.'),
97
+ step('compare', 'Compare registry publications offline', 'compare', ['<saved-lookup.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.compare', 'Do not convert conflicting or unavailable publications into equivalence.'),
98
+ step('report', 'Prepare a target-free source report', 'source-report', ['<saved-lookup.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.source-reliability-report', 'The report describes source behaviour, not ownership, safety, or legal status.'),
99
+ ]),
100
+ }),
101
+ 'evidence-handoff': Object.freeze({
102
+ id: 'evidence-handoff',
103
+ label: 'Reviewed evidence handoff',
104
+ subjectRequirement: 'review_label',
105
+ objective: 'Verify, minimise, and package analyst-selected evidence for a deliberate handoff without transmitting or submitting it.',
106
+ runnable: false,
107
+ limitations: Object.freeze(['The recipe prepares local material only; sharing remains a separate deliberate action.']),
108
+ steps: () => Object.freeze([
109
+ step('verify', 'Verify the selected artefact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Verification checks structure and integrity, not the truth or currency of observations.'),
110
+ step('package', 'Build a reviewed public Case-pack', 'case-pack', ['<cases.json>', '--audience', 'public', '--reviewed', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.case-pack', 'Review minimisation and audience projection before retaining the separate package.'),
111
+ step('lint', 'Review deliberate-sharing metadata', 'sharing-review', ['<package.json>', '--marking', 'clear', '--recipient-scope', 'public', '--purpose', 'reviewed evidence handoff', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.sharing-review', 'A clear lint result does not send, upload, publish, or authorise the artefact.'),
112
+ ]),
113
+ }),
114
+ 'planned-domain-change': Object.freeze({
115
+ id: 'planned-domain-change',
116
+ label: 'Planned domain change',
117
+ subjectRequirement: 'domain',
118
+ objective: 'Review an analyst-authored desired state and prepare bounded change material without changing DNS, registry, mail, or hosted configuration.',
119
+ runnable: false,
120
+ limitations: Object.freeze(['Planning and packaging never apply, submit, schedule, or enforce a change.']),
121
+ steps: () => Object.freeze([
122
+ step('control', 'Review the desired-state manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-control-review', 'Only supplied complete observations may produce drift.'),
123
+ step('assure', 'Review change and recovery assumptions', 'assurance', ['<assurance-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-assurance', 'Record uncertainty, rollback dependencies, and unavailable evidence explicitly.'),
124
+ step('package', 'Build a reviewed change packet', 'change-packet', ['<change-packet-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-change-packet', 'The packet is local reviewed material and performs no submission or enforcement.'),
125
+ ]),
126
+ }),
127
+ 'post-change-verification': Object.freeze({
128
+ id: 'post-change-verification',
129
+ label: 'Post-change verification',
130
+ subjectRequirement: 'domain',
131
+ objective: 'Perform one explicit later observation and compare it with analyst-selected retained evidence after an authorised change.',
132
+ runnable: false,
133
+ limitations: Object.freeze(['This is a one-time recheck, not monitoring setup or proof that every resolver or service has converged.']),
134
+ steps: () => Object.freeze([
135
+ step('recheck', 'Run one bounded retained-manifest review', 'monitor-once', ['<manifest.json>', '--limit', '1', '--json'], 'network', 'network_disclosure', 'whoisleuth.domain-control-review', 'One later observation may remain partial, unavailable, stale, or conflicting.'),
136
+ step('compare', 'Compare selected before and after evidence', 'diff', ['<before.json>', '<after.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Materiality is derived from compatible retained evidence and does not infer intent.'),
137
+ step('record', 'Record reviewed completion material', 'change-packet', ['<post-change-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-change-packet', 'Recording reviewed material does not submit it or start automatic monitoring.'),
138
+ ]),
139
+ }),
51
140
  });
141
+ export const INVESTIGATION_RECIPE_REGISTRY = RECIPES;
142
+ export const INVESTIGATION_PLAN_RECIPE_LABELS = Object.freeze(Object.fromEntries(INVESTIGATION_PLAN_RECIPES.map((recipe) => [recipe, RECIPES[recipe].label])));
143
+ export function isRunnableInvestigationRecipe(value) {
144
+ return RUNNABLE_INVESTIGATION_PLAN_RECIPES.includes(value);
145
+ }
146
+ function recipeCatalogueEntry(recipe) {
147
+ const exampleSubject = recipe.subjectRequirement === 'domain' ? 'example.test' : 'Example Organisation';
148
+ const steps = recipe.steps(exampleSubject).map((item) => Object.freeze({
149
+ id: item.id,
150
+ label: item.label,
151
+ command: item.command,
152
+ exampleArguments: item.arguments,
153
+ mode: item.mode,
154
+ approval: item.approval,
155
+ produces: item.produces,
156
+ completion: item.completion,
157
+ }));
158
+ return Object.freeze({
159
+ id: recipe.id,
160
+ label: recipe.label,
161
+ objective: recipe.objective,
162
+ subjectRequirement: recipe.subjectRequirement,
163
+ runnableByWorkflowRun: recipe.runnable,
164
+ networkModes: Object.freeze([...new Set(steps.map((item) => item.mode))]),
165
+ approvals: Object.freeze([...new Set(steps.map((item) => item.approval))]),
166
+ steps: Object.freeze(steps),
167
+ limitations: recipe.limitations,
168
+ });
169
+ }
170
+ export function buildWorkflowRecipeCatalogue(recipeId = null) {
171
+ const selected = recipeId === null ? INVESTIGATION_PLAN_RECIPES : [recipeId];
172
+ return Object.freeze({
173
+ schema: CLI_WORKFLOW_RECIPE_CATALOGUE_SCHEMA,
174
+ version: CLI_WORKFLOW_RECIPE_CATALOGUE_VERSION,
175
+ recipes: Object.freeze(selected.map((id) => recipeCatalogueEntry(RECIPES[id]))),
176
+ limitations: Object.freeze([
177
+ 'Catalogue and explanation modes are fixed metadata. They make no request, read no evidence file, and execute no step.',
178
+ 'workflow-run remains limited to recipes explicitly marked runnable by the installed registry.',
179
+ ]),
180
+ });
181
+ }
182
+ export function formatWorkflowRecipeCatalogue(catalogue) {
183
+ return [
184
+ 'WHOISleuth workflow recipes',
185
+ '',
186
+ ...catalogue.recipes.flatMap((recipe) => [
187
+ `${recipe.id} — ${recipe.label}`,
188
+ ` Subject: ${recipe.subjectRequirement.replaceAll('_', ' ')}`,
189
+ ` Workflow run: ${recipe.runnableByWorkflowRun ? 'supported' : 'plan and explanation only'}`,
190
+ ` ${recipe.objective}`,
191
+ ...recipe.steps.map((item, index) => ` ${index + 1}. ${item.label} [${item.mode}; ${item.approval.replaceAll('_', ' ')}]`),
192
+ ...recipe.limitations.map((item) => ` Limitation: ${item}`),
193
+ '',
194
+ ]),
195
+ ...catalogue.limitations.map((item) => `Limitation: ${item}`),
196
+ '',
197
+ ].join('\n');
198
+ }
52
199
  function step(id, label, command, args, mode, approval, produces, completion) {
200
+ const invocationEffect = cliInvocationNetworkEffect(command, args);
201
+ if ((mode === 'network') !== (invocationEffect === 'network')) {
202
+ throw new Error(`Investigation step ${id} does not match the registered network effect for ${command}.`);
203
+ }
53
204
  return Object.freeze({ id, label, command, arguments: Object.freeze([...args]), mode, approval, produces, completion });
54
205
  }
55
206
  function boundedSubject(value) {
@@ -71,17 +222,17 @@ export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue
71
222
  if (!recipe)
72
223
  throw new TypeError('Investigation-plan recipe is unsupported.');
73
224
  const suppliedSubject = boundedSubject(subjectValue);
74
- const subject = recipe.requiresDomain ? normalizedDomain(suppliedSubject) : suppliedSubject.toLowerCase();
225
+ const subject = recipe.subjectRequirement === 'domain' ? normalizedDomain(suppliedSubject) : suppliedSubject.toLowerCase();
75
226
  if (!subject)
76
227
  throw new TypeError('This investigation-plan recipe requires one valid domain.');
77
- const parsedTime = Date.parse(generatedAtValue);
78
- if (!Number.isFinite(parsedTime))
79
- throw new TypeError('Investigation-plan generation time is invalid.');
228
+ const generatedAt = normalizeExplicitIsoTimestamp(generatedAtValue);
229
+ if (!generatedAt)
230
+ throw new TypeError('Investigation-plan generation time must use an explicit timezone.');
80
231
  const steps = recipe.steps(subject);
81
232
  return Object.freeze({
82
233
  schema: CLI_INVESTIGATION_PLAN_SCHEMA,
83
234
  version: CLI_INVESTIGATION_PLAN_VERSION,
84
- generatedAt: new Date(parsedTime).toISOString(),
235
+ generatedAt,
85
236
  recipe: Object.freeze({ id: recipeId, label: recipe.label, objective: recipe.objective }),
86
237
  subject,
87
238
  execution: 'plan_only',
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { buildInvestigationPlan, } from "./investigation-plan.mjs";
2
+ import { buildInvestigationPlan, isRunnableInvestigationRecipe, } from "./investigation-plan.mjs";
3
3
  import { CliUsageError } from "./errors.mjs";
4
+ import EXIT_CODES from "./exit-codes.mjs";
4
5
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
5
6
  export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
6
7
  export const CLI_INVESTIGATION_RUN_VERSION = 1;
@@ -95,12 +96,16 @@ function parseResumeState(input, plan) {
95
96
  return completed;
96
97
  }
97
98
  export async function runInvestigationRecipe(recipe, subjectValue, options) {
99
+ if (!isRunnableInvestigationRecipe(recipe)) {
100
+ throw new CliUsageError('workflow-run supports only installed recipes whose exact steps satisfy the execution contract.');
101
+ }
98
102
  const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
99
103
  const prior = parseResumeState(options.resumeInput, plan);
100
104
  const completed = [...prior];
101
105
  let state = 'complete';
102
106
  let currentStep = null;
103
107
  for (const step of plan.steps) {
108
+ options.signal?.throwIfAborted();
104
109
  if (completed.some((item) => item.id === step.id))
105
110
  continue;
106
111
  currentStep = step;
@@ -113,6 +118,9 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
113
118
  break;
114
119
  }
115
120
  const result = await options.execute(step.command, step.arguments);
121
+ if (result.exitCode === EXIT_CODES.CANCELLED) {
122
+ throw options.signal?.reason || new DOMException('Cancelled', 'AbortError');
123
+ }
116
124
  const parsedResult = boundedResult(result.stdout);
117
125
  if ((result.exitCode === 0 || result.exitCode === 2) && resultSchema(parsedResult) !== step.produces) {
118
126
  throw new CliUsageError(`Investigation step ${step.id} returned an unexpected command contract.`);
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
2
3
  const LOOKALIKE_CALIBRATION_INPUT_SCHEMA = 'whoisleuth.lookalike-calibration-input';
3
4
  const LOOKALIKE_CALIBRATION_SCHEMA = 'whoisleuth.lookalike-calibration';
4
5
  const LOOKALIKE_CALIBRATION_VERSION = 1;
@@ -29,10 +30,11 @@ function buildLookalikeCalibration(raw, generatedAt = new Date().toISOString())
29
30
  }
30
31
  let parsed;
31
32
  try {
33
+ scanBoundedJson(raw);
32
34
  parsed = JSON.parse(raw);
33
35
  }
34
36
  catch {
35
- throw new TypeError('Lookalike calibration input must be valid JSON.');
37
+ throw new TypeError('Lookalike calibration input must be valid bounded JSON without duplicate keys.');
36
38
  }
37
39
  const input = record(parsed);
38
40
  if (input.schema !== LOOKALIKE_CALIBRATION_INPUT_SCHEMA || input.version !== LOOKALIKE_CALIBRATION_VERSION) {