@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,15 +1,17 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { buildInvestigationPlan, isRunnableInvestigationRecipe, } from "./investigation-plan.mjs";
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { buildInvestigationPlan, isRunnableInvestigationRecipe, workflowReviewDeclarations, workflowStandardInputs, } from "./investigation-plan.mjs";
3
4
  import { CliUsageError } from "./errors.mjs";
4
5
  import { hasUnsafeCliText } from "./errors.mjs";
5
6
  import EXIT_CODES from "./exit-codes.mjs";
6
7
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
7
- export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
8
- export const CLI_INVESTIGATION_RUN_VERSION = 2;
9
- export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
10
- export const MAX_INVESTIGATION_RUN_SELECTIONS = 16;
11
- export const MAX_INVESTIGATION_RUN_SELECTION_LENGTH = 1_024;
8
+ import { CLI_INVESTIGATION_RUN_SCHEMA, CLI_INVESTIGATION_RUN_VERSION, SUPPORTED_CLI_INVESTIGATION_RUN_VERSIONS, MAX_INVESTIGATION_RUN_BYTES, MAX_INVESTIGATION_RUN_SELECTIONS, MAX_INVESTIGATION_RUN_SELECTION_LENGTH, } from "../packages/contracts/investigation-run.mjs";
9
+ import { normalizeWorkflowBindings, validateWorkflowResult, workflowArtifactReference, workflowInputSchemas, } from "./investigation-artifacts.mjs";
10
+ export { CLI_INVESTIGATION_RUN_SCHEMA, CLI_INVESTIGATION_RUN_VERSION, SUPPORTED_CLI_INVESTIGATION_RUN_VERSIONS, MAX_INVESTIGATION_RUN_BYTES, MAX_INVESTIGATION_RUN_SELECTIONS, MAX_INVESTIGATION_RUN_SELECTION_LENGTH, };
12
11
  const PLACEHOLDER_PATTERN = /^<[^>]+>$/u;
12
+ // These collectors can return useful incomplete observations. Offline validation
13
+ // and export failures are not observations and must remain retryable failures.
14
+ const PARTIAL_OBSERVATION_COMMANDS = new Set(['lookup', 'posture', 'discover-scan', 'ct-search', 'monitor-once']);
13
15
  function boundedResult(value) {
14
16
  if (Buffer.byteLength(value, 'utf8') > MAX_INVESTIGATION_RUN_BYTES) {
15
17
  throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
@@ -25,28 +27,56 @@ function boundedResult(value) {
25
27
  return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').slice(0, MAX_INVESTIGATION_RUN_BYTES);
26
28
  }
27
29
  }
28
- function resultSchema(value) {
29
- return value && typeof value === 'object' && !Array.isArray(value) && typeof value.schema === 'string'
30
- ? String(value.schema)
31
- : null;
30
+ function stepDisposition(step, exitCode, result, retained = false) {
31
+ const partial = exitCode === EXIT_CODES.PARTIAL_FAILURE && step.mode === 'network'
32
+ && PARTIAL_OBSERVATION_COMMANDS.has(step.command);
33
+ if (exitCode === EXIT_CODES.SUCCESS || partial) {
34
+ try {
35
+ const artifact = validateWorkflowResult(step, result, retained);
36
+ return { disposition: partial ? 'partial' : 'complete', artifact };
37
+ }
38
+ catch {
39
+ if (!partial)
40
+ throw new CliUsageError(`Investigation step ${step.id} returned an unexpected command contract or invalid result.`);
41
+ }
42
+ }
43
+ return { disposition: 'failed', artifact: null };
32
44
  }
33
45
  function sameArguments(left, right) {
34
46
  return Array.isArray(left)
35
47
  && left.length === right.length
36
48
  && left.every((value, index) => typeof value === 'string' && value === right[index]);
37
49
  }
38
- function selectedArguments(step, values) {
50
+ function selectedArguments(step, values, bindings, completed) {
39
51
  let selectionIndex = 0;
52
+ let input = 0;
40
53
  return Object.freeze(step.arguments.map((argument) => {
41
54
  if (!PLACEHOLDER_PATTERN.test(argument))
42
55
  return argument;
56
+ input += 1;
57
+ const binding = bindings.find((item) => item.stepId === step.id && item.input === input);
58
+ if (binding) {
59
+ const artifact = completed.find((item) => item.id === binding.sourceStepId)?.artifact;
60
+ return artifact ? workflowArtifactReference(artifact) : argument;
61
+ }
43
62
  const selected = values[selectionIndex];
44
63
  selectionIndex += 1;
45
64
  return selected ?? argument;
46
65
  }));
47
66
  }
48
- function selectedStep(step, values) {
49
- return Object.freeze({ ...step, arguments: selectedArguments(step, values) });
67
+ function selectedStep(step, values, bindings, completed) {
68
+ return Object.freeze({ ...step, arguments: selectedArguments(step, values, bindings, completed) });
69
+ }
70
+ function resolveStepInputs(step, bindings, completed) {
71
+ const files = new Map();
72
+ const inputs = bindings.filter((binding) => binding.stepId === step.id).map((binding) => {
73
+ const source = completed.find((item) => item.id === binding.sourceStepId);
74
+ if (!source?.artifact)
75
+ throw new CliUsageError(`Workflow step ${step.id} has no validated source artefact.`);
76
+ files.set(workflowArtifactReference(source.artifact), JSON.stringify(source.result));
77
+ return Object.freeze({ input: binding.input, sourceStepId: source.id, artifactId: source.artifact.id });
78
+ });
79
+ return { files, inputs: Object.freeze(inputs) };
50
80
  }
51
81
  function placeholderCount(step) {
52
82
  return step.arguments.filter((argument) => PLACEHOLDER_PATTERN.test(argument)).length;
@@ -107,11 +137,11 @@ function normalizeInvocationSelections(plan, selections) {
107
137
  }
108
138
  return normalizeRetainedSelections(plan, [...grouped].map(([stepId, values]) => ({ stepId, values })), 'workflow-run selections');
109
139
  }
110
- function mergeSelections(plan, retained, supplied, completed) {
140
+ function mergeSelections(plan, retained, supplied, completed, bindings) {
111
141
  const merged = new Map(retained.map((item) => [item.stepId, item.values]));
112
142
  for (const item of supplied) {
113
143
  const completedStep = completed.find((step) => step.id === item.stepId);
114
- if (completedStep && !sameArguments(completedStep.arguments, selectedArguments(plan.steps.find((step) => step.id === item.stepId), item.values))) {
144
+ if (completedStep && !sameArguments(completedStep.arguments, selectedArguments(plan.steps.find((step) => step.id === item.stepId), item.values, bindings, completed))) {
115
145
  throw new CliUsageError(`workflow-run cannot change analyst selections for completed step ${item.stepId}.`);
116
146
  }
117
147
  merged.set(item.stepId, item.values);
@@ -121,9 +151,36 @@ function mergeSelections(plan, retained, supplied, completed) {
121
151
  return values ? [Object.freeze({ stepId: step.id, values: Object.freeze([...values]) })] : [];
122
152
  });
123
153
  }
154
+ function mergeBindings(plan, retained, supplied, completed) {
155
+ const merged = [...retained];
156
+ for (const binding of supplied) {
157
+ const index = merged.findIndex((item) => item.stepId === binding.stepId && item.input === binding.input);
158
+ if (completed.some((step) => step.id === binding.stepId) && !isDeepStrictEqual(merged[index], binding)) {
159
+ throw new CliUsageError(`workflow-run cannot change artefact inputs for completed step ${binding.stepId}.`);
160
+ }
161
+ if (index === -1)
162
+ merged.push(binding);
163
+ else
164
+ merged[index] = binding;
165
+ }
166
+ return normalizeWorkflowBindings(plan, merged);
167
+ }
168
+ function validateSelectedInputs(plan, selections, bindings, completed) {
169
+ for (const selection of selections) {
170
+ const step = plan.steps.find((item) => item.id === selection.stepId);
171
+ if (selection.values.length + bindings.filter((item) => item.stepId === step.id).length > placeholderCount(step)) {
172
+ throw new CliUsageError(`Workflow step ${step.id} has more selections and artefact bindings than inputs.`);
173
+ }
174
+ // Ordinary filenames remain literal. A caller cannot make a selected file
175
+ // alias an explicitly bound in-memory document in the same invocation.
176
+ if (selection.values.some((value) => bindings.some((binding) => binding.stepId === step.id && completed.some((item) => item.id === binding.sourceStepId && item.artifact && value === workflowArtifactReference(item.artifact))))) {
177
+ throw new CliUsageError(`Workflow step ${step.id} has a filename that conflicts with an artefact reference.`);
178
+ }
179
+ }
180
+ }
124
181
  function parseResumeState(input, plan) {
125
182
  if (!input)
126
- return Object.freeze({ completed: Object.freeze([]), selections: Object.freeze([]) });
183
+ return Object.freeze({ completed: Object.freeze([]), selections: Object.freeze([]), bindings: Object.freeze([]) });
127
184
  if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
128
185
  throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
129
186
  const normalizedInput = input.replace(/^\uFEFF/u, '');
@@ -145,7 +202,7 @@ function parseResumeState(input, plan) {
145
202
  }
146
203
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
147
204
  if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA
148
- || (root.version !== 1 && root.version !== CLI_INVESTIGATION_RUN_VERSION)
205
+ || !SUPPORTED_CLI_INVESTIGATION_RUN_VERSIONS.some((version) => root.version === version)
149
206
  || root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
150
207
  throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
151
208
  }
@@ -155,6 +212,11 @@ function parseResumeState(input, plan) {
155
212
  if (root.version === 1 && root.selections !== undefined) {
156
213
  throw new CliUsageError('Investigation resume state version 1 cannot contain analyst selections.');
157
214
  }
215
+ const bindings = root.version === CLI_INVESTIGATION_RUN_VERSION ? normalizeWorkflowBindings(plan, root.artifactBindings) : [];
216
+ if (root.version !== CLI_INVESTIGATION_RUN_VERSION && root.artifactBindings !== undefined) {
217
+ throw new CliUsageError('Legacy investigation checkpoints cannot declare artefact bindings.');
218
+ }
219
+ validateSelectedInputs(plan, selections, bindings, []);
158
220
  const selectionsByStep = new Map(selections.map((item) => [item.stepId, item.values]));
159
221
  if (root.completedSteps.length > plan.steps.length) {
160
222
  throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
@@ -162,7 +224,7 @@ function parseResumeState(input, plan) {
162
224
  const completed = [];
163
225
  for (const [index, value] of root.completedSteps.entries()) {
164
226
  const baseStep = plan.steps[index];
165
- const planned = baseStep ? selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? []) : null;
227
+ const planned = baseStep ? selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? [], bindings, completed) : null;
166
228
  if (!planned || !value || typeof value !== 'object' || Array.isArray(value)) {
167
229
  throw new CliUsageError('Investigation resume state contains a malformed or out-of-order step.');
168
230
  }
@@ -175,15 +237,22 @@ function parseResumeState(input, plan) {
175
237
  throw new CliUsageError('Investigation resume state does not match the installed fixed recipe.');
176
238
  }
177
239
  const exitCode = Number(item.exitCode);
178
- if (exitCode !== 0 && exitCode !== 2) {
240
+ const { disposition, artifact } = stepDisposition(planned, exitCode, item.result, true);
241
+ const { inputs } = resolveStepInputs(planned, bindings, completed);
242
+ if (root.version === CLI_INVESTIGATION_RUN_VERSION) {
243
+ if (!isDeepStrictEqual(item.artifact, artifact) || !isDeepStrictEqual(item.inputs, inputs)) {
244
+ throw new CliUsageError('Investigation checkpoint artefact identity or input bindings do not match the retained results.');
245
+ }
246
+ }
247
+ else if (item.artifact !== undefined || item.inputs !== undefined) {
248
+ throw new CliUsageError('Legacy investigation steps cannot declare artefact identities.');
249
+ }
250
+ if (disposition === 'failed') {
179
251
  if (index !== root.completedSteps.length - 1) {
180
252
  throw new CliUsageError('A failed investigation step must be the final retained step.');
181
253
  }
182
254
  break;
183
255
  }
184
- if (resultSchema(item.result) !== planned.produces) {
185
- throw new CliUsageError('Investigation resume state contains output from an unexpected command contract.');
186
- }
187
256
  completed.push(Object.freeze({
188
257
  id: planned.id,
189
258
  command: planned.command,
@@ -191,27 +260,58 @@ function parseResumeState(input, plan) {
191
260
  mode: planned.mode,
192
261
  exitCode,
193
262
  result: item.result,
263
+ artifact,
264
+ inputs,
194
265
  }));
195
266
  }
196
- return Object.freeze({ completed: Object.freeze(completed), selections });
267
+ validateSelectedInputs(plan, selections, bindings, completed);
268
+ return Object.freeze({ completed: Object.freeze(completed), selections, bindings });
197
269
  }
198
270
  export async function runInvestigationRecipe(recipe, subjectValue, options) {
199
271
  if (!isRunnableInvestigationRecipe(recipe)) {
200
272
  throw new CliUsageError('workflow-run supports only installed recipes whose exact steps satisfy the execution contract.');
201
273
  }
202
274
  const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
275
+ const confirmedReviews = options.confirmedReviews ?? [];
276
+ if (!Array.isArray(confirmedReviews) || confirmedReviews.length > plan.steps.length
277
+ || new Set(confirmedReviews).size !== confirmedReviews.length
278
+ || confirmedReviews.some(id => !plan.steps.some(step => step.id === id && workflowReviewDeclarations(step).length))) {
279
+ throw new CliUsageError('--confirm-review must name each selected review-declaration step at most once.');
280
+ }
203
281
  const prior = parseResumeState(options.resumeInput, plan);
204
282
  const suppliedSelections = normalizeInvocationSelections(plan, options.selections ?? []);
205
- const selections = mergeSelections(plan, prior.selections, suppliedSelections, prior.completed);
283
+ // A complete explicit input list replaces a not-yet-run step's standard or
284
+ // retained connections. Partial lists fill the remaining unbound slots.
285
+ const explicitSteps = new Set(suppliedSelections.filter(selection => selection.values.length === placeholderCount(plan.steps.find(step => step.id === selection.stepId))
286
+ && !prior.completed.some(step => step.id === selection.stepId)).map(selection => selection.stepId));
287
+ const initialBindings = options.resumeInput === null ? workflowStandardInputs(recipe) : prior.bindings;
288
+ const bindings = mergeBindings(plan, initialBindings.filter(binding => !explicitSteps.has(binding.stepId)), normalizeWorkflowBindings(plan, options.artifactBindings ?? []), prior.completed);
289
+ let selections = mergeSelections(plan, prior.selections, suppliedSelections, prior.completed, bindings);
290
+ validateSelectedInputs(plan, selections, bindings, prior.completed);
206
291
  const selectionsByStep = new Map(selections.map((item) => [item.stepId, item.values]));
207
292
  const completed = [...prior.completed];
208
- let state = 'complete';
293
+ let state = completed.some((step) => step.exitCode === EXIT_CODES.PARTIAL_FAILURE) ? 'partial' : 'complete';
209
294
  let currentStep = null;
210
295
  for (const baseStep of plan.steps) {
211
296
  options.signal?.throwIfAborted();
212
- const step = selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? []);
297
+ let step = selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? [], bindings, completed);
213
298
  if (completed.some((item) => item.id === step.id))
214
299
  continue;
300
+ const missingInputs = step.arguments.filter(argument => PLACEHOLDER_PATTERN.test(argument));
301
+ if (missingInputs.length && options.selectInputs) {
302
+ const supplied = await options.selectInputs({ stepId: step.id, label: step.label, inputs: missingInputs });
303
+ options.signal?.throwIfAborted();
304
+ if (!Array.isArray(supplied) || supplied.length > missingInputs.length)
305
+ throw new CliUsageError('Interactive selections exceed the remaining fixed-recipe inputs.');
306
+ if (supplied.length) {
307
+ const values = [...(selectionsByStep.get(step.id) ?? []), ...supplied];
308
+ const added = normalizeRetainedSelections(plan, [{ stepId: step.id, values }], 'Interactive selections');
309
+ selections = mergeSelections(plan, selections, added, completed, bindings);
310
+ validateSelectedInputs(plan, selections, bindings, completed);
311
+ selectionsByStep.set(step.id, values);
312
+ step = selectedStep(baseStep, values, bindings, completed);
313
+ }
314
+ }
215
315
  currentStep = step;
216
316
  if (step.arguments.some((argument) => /^<[^>]+>$/u.test(argument))) {
217
317
  state = 'awaiting_analyst_selection';
@@ -221,14 +321,19 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
221
321
  state = 'awaiting_network_approval';
222
322
  break;
223
323
  }
224
- const result = await options.execute(step.command, step.arguments);
324
+ if (workflowReviewDeclarations(step).length && !confirmedReviews.includes(step.id)) {
325
+ state = 'awaiting_review_confirmation';
326
+ break;
327
+ }
328
+ validateSelectedInputs(plan, selections, bindings, completed);
329
+ const { files, inputs } = resolveStepInputs(step, bindings, completed);
330
+ const result = await options.execute(step.command, step.arguments, files);
331
+ options.signal?.throwIfAborted();
225
332
  if (result.exitCode === EXIT_CODES.CANCELLED) {
226
333
  throw options.signal?.reason || new DOMException('Cancelled', 'AbortError');
227
334
  }
228
335
  const parsedResult = boundedResult(result.stdout);
229
- if ((result.exitCode === 0 || result.exitCode === 2) && resultSchema(parsedResult) !== step.produces) {
230
- throw new CliUsageError(`Investigation step ${step.id} returned an unexpected command contract.`);
231
- }
336
+ const { disposition, artifact } = stepDisposition(step, result.exitCode, parsedResult);
232
337
  completed.push(Object.freeze({
233
338
  id: step.id,
234
339
  command: step.command,
@@ -236,11 +341,17 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
236
341
  mode: step.mode,
237
342
  exitCode: result.exitCode,
238
343
  result: parsedResult,
344
+ artifact,
345
+ inputs,
239
346
  }));
240
- if (result.exitCode !== 0 && result.exitCode !== 2) {
347
+ if (disposition === 'failed') {
241
348
  state = 'step_failed';
242
349
  break;
243
350
  }
351
+ if (disposition === 'partial') {
352
+ state = 'partial';
353
+ break;
354
+ }
244
355
  currentStep = null;
245
356
  }
246
357
  return Object.freeze({
@@ -251,23 +362,67 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
251
362
  subject: plan.subject,
252
363
  state,
253
364
  networkApprovedForThisRun: options.approveNetwork,
365
+ reviewsConfirmedForThisRun: Object.freeze([...confirmedReviews]),
254
366
  selections: Object.freeze(selections),
367
+ artifactBindings: bindings,
255
368
  completedSteps: Object.freeze(completed),
256
369
  currentStep,
257
370
  limitations: Object.freeze([
258
371
  'Only commands and arguments from the installed fixed recipe can execute; no shell, script, arbitrary command, or enforcement action is accepted.',
259
372
  'Network steps run only with --approve-network for the current invocation. Unresolved analyst selections pause; supplied values replace exact placeholders and are passed as arguments without shell interpretation.',
373
+ 'Steps declaring human review require --confirm-review for that exact step in the current invocation. Confirm only after reviewing its selected material and each listed declaration; checkpoint metadata never grants permission to another step.',
260
374
  'A resume file is a local checkpoint and can retain selected local paths or values. It is not proof that prior evidence remains current or that a human reviewed each stored result.',
375
+ 'Standard and explicit artefact bindings reuse validated compatible earlier outputs without temporary extraction files. Full explicit input lists override uncompleted step connections. Resumes preserve their recorded connections; no new standard inputs are added to an older checkpoint. Content digests identify retained JSON, not source authenticity or currency.',
376
+ 'An incomplete collection pauses for review. Resuming retains it without recollection; the run remains partial even after later steps finish. Failed validation or export steps are retried, not accepted as evidence.',
261
377
  ]),
262
378
  });
263
379
  }
380
+ export function investigationRunExitCode(document) {
381
+ if (document.state === 'step_failed') {
382
+ const code = document.completedSteps.at(-1)?.exitCode;
383
+ return typeof code === 'number' && code !== EXIT_CODES.SUCCESS && Object.values(EXIT_CODES).some((known) => code === known)
384
+ ? code : EXIT_CODES.INTERNAL_ERROR;
385
+ }
386
+ return document.completedSteps.some((step) => step.exitCode === EXIT_CODES.PARTIAL_FAILURE)
387
+ ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
388
+ }
264
389
  export function formatInvestigationRun(document) {
390
+ const stepLabel = document.state === 'step_failed' ? 'Failed' : document.state === 'partial' ? 'Review' : 'Next';
391
+ const plan = buildInvestigationPlan(document.recipe, document.subject, document.generatedAt);
392
+ const accepted = document.completedSteps.filter(step => step.artifact);
393
+ const current = document.currentStep;
394
+ const inputs = current?.arguments.flatMap((argument, position) => {
395
+ if (!PLACEHOLDER_PATTERN.test(argument))
396
+ return [];
397
+ // Slots refer to the fixed recipe, including inputs already bound or selected.
398
+ const base = plan.steps.find(step => step.id === current.id);
399
+ const input = base.arguments.slice(0, position + 1).filter(value => PLACEHOLDER_PATTERN.test(value)).length;
400
+ const compatible = workflowInputSchemas(current.command);
401
+ const reusable = accepted.filter(step => step.artifact && compatible.includes(step.artifact.schema));
402
+ return [
403
+ `Input ${current.id}:${input} ${argument} — --select ${current.id}=<path-or-value>`,
404
+ ...reusable.map(source => ` Reuse deliberately: --use-artifact ${current.id}:${input}=${source.id}`),
405
+ ];
406
+ }) ?? [];
265
407
  return [
266
408
  `Investigation run: ${document.recipe}`,
267
409
  `Subject ${document.subject}`,
268
410
  `State ${document.state.replaceAll('_', ' ')}`,
269
- `Completed ${document.completedSteps.length}`,
270
- ...(document.currentStep ? [`Next ${document.currentStep.label}`, `Approval ${document.currentStep.approval.replaceAll('_', ' ')}`] : []),
411
+ `Progress ${accepted.length} of ${plan.steps.length} steps retained`,
412
+ ...accepted.map(step => ` ${step.id}: ${step.exitCode === EXIT_CODES.PARTIAL_FAILURE ? 'partial observation' : 'completed'} · ${step.artifact.schema} v${step.artifact.version} · ${step.artifact.id}`),
413
+ ...(document.currentStep ? [
414
+ `${stepLabel.padEnd(11)}${document.currentStep.label}`,
415
+ ...(document.state === 'partial' || document.state === 'step_failed' ? [] : [`Approval ${document.currentStep.approval.replaceAll('_', ' ')}`]),
416
+ ] : []),
417
+ ...inputs,
418
+ ...(document.state === 'awaiting_network_approval' ? ['Continue with --approve-network only after reviewing the next command’s network disclosure.'] : []),
419
+ ...(document.state === 'awaiting_review_confirmation' && current ? [
420
+ `Declarations: ${workflowReviewDeclarations(current).join(', ')}`,
421
+ `After reviewing the selected material, continue with --confirm-review ${current.id}.`,
422
+ ] : []),
423
+ ...(current ? ['Keep the JSON checkpoint with --json --output <run.json>, then continue the same recipe and subject with --resume <run.json>.'] : [
424
+ document.state === 'partial' ? 'All steps have run; retained incomplete observations remain incomplete.' : 'All recipe steps have run. This is not an analyst verdict or proof that every finding is resolved.',
425
+ ]),
271
426
  '',
272
427
  ].join('\n');
273
428
  }
@@ -0,0 +1,170 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, open, realpath, unlink } from 'node:fs/promises';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { decodeBoundedUtf8, readBoundedRegularFile } from "../lib/bounded-file.mjs";
5
+ import { CliUsageError } from "./errors.mjs";
6
+ import { writePrivateFile } from "./output-file.mjs";
7
+ function missing(error) {
8
+ return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT');
9
+ }
10
+ async function canonicalPath(value, allowMissing, label) {
11
+ const absolute = resolve(value);
12
+ if (basename(absolute).endsWith('.workflow.lock'))
13
+ throw new CliUsageError('The .workflow.lock suffix is reserved for workflow file ownership.');
14
+ try {
15
+ const metadata = await lstat(absolute);
16
+ if (!metadata.isFile() || metadata.nlink !== 1) {
17
+ throw new CliUsageError(`${label} files must be regular files without symbolic or additional hard links.`);
18
+ }
19
+ return await realpath(absolute);
20
+ }
21
+ catch (error) {
22
+ if (!allowMissing || !missing(error))
23
+ throw error;
24
+ return join(await realpath(dirname(absolute)), basename(absolute));
25
+ }
26
+ }
27
+ async function snapshot(path, maximumBytes, label, signal) {
28
+ let before;
29
+ try {
30
+ before = await lstat(path);
31
+ }
32
+ catch (error) {
33
+ if (missing(error))
34
+ return null;
35
+ throw error;
36
+ }
37
+ if (!before.isFile() || before.nlink !== 1)
38
+ throw new CliUsageError(`${label} files must be regular files without links.`);
39
+ const bytes = await readBoundedRegularFile(path, {
40
+ maximumBytes, label: `${label} file`, ...(signal ? { signal } : {}),
41
+ });
42
+ const after = await lstat(path);
43
+ if (!after.isFile() || after.nlink !== 1 || before.dev !== after.dev || before.ino !== after.ino
44
+ || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || before.size !== after.size) {
45
+ throw new CliUsageError(`${label} file changed while it was being read.`);
46
+ }
47
+ return {
48
+ identity: { device: after.dev, inode: after.ino, modified: after.mtimeMs, changed: after.ctimeMs,
49
+ bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') },
50
+ text: decodeBoundedUtf8(bytes, `${label} file`),
51
+ };
52
+ }
53
+ async function ownsLease(lease) {
54
+ try {
55
+ const current = await lstat(lease.path);
56
+ return current.isFile() && current.nlink === 1 && current.dev === lease.device && current.ino === lease.inode;
57
+ }
58
+ catch (error) {
59
+ if (missing(error))
60
+ return false;
61
+ throw error;
62
+ }
63
+ }
64
+ /** One selected document write, holding source and destination ownership until publication. */
65
+ export async function prepareLocalDocumentWrite(options) {
66
+ if (![options.maximumInputBytes, options.maximumOutputBytes].every(value => Number.isSafeInteger(value) && value > 0)) {
67
+ throw new TypeError('Local document byte limits must be positive safe integers.');
68
+ }
69
+ const label = options.label;
70
+ const leases = [];
71
+ let released = false;
72
+ let published = false;
73
+ async function release() {
74
+ released = true;
75
+ let unresolved = 0;
76
+ for (const lease of leases.splice(0).reverse()) {
77
+ try {
78
+ if (await ownsLease(lease))
79
+ await unlink(lease.path);
80
+ else
81
+ unresolved += 1;
82
+ }
83
+ catch {
84
+ unresolved += 1;
85
+ }
86
+ }
87
+ return unresolved;
88
+ }
89
+ try {
90
+ options.signal?.throwIfAborted();
91
+ const destination = await canonicalPath(options.destination, true, label);
92
+ const source = options.source ? await canonicalPath(options.source, false, label) : null;
93
+ if (source === destination && options.allowSourceReplacement === false) {
94
+ throw new CliUsageError(`${label} review output must use a different path from its source file.`);
95
+ }
96
+ const paths = [...new Set([destination, ...(source ? [source] : [])])].sort();
97
+ for (const path of paths) {
98
+ options.signal?.throwIfAborted();
99
+ const leasePath = join(dirname(path), `.${basename(path)}.workflow.lock`);
100
+ let handle;
101
+ try {
102
+ handle = await open(leasePath, 'wx', 0o600);
103
+ }
104
+ catch (error) {
105
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
106
+ throw new CliUsageError(`${label} is already locked. Wait for its owner; after an interrupted process, inspect and remove only its abandoned .workflow.lock file.`);
107
+ }
108
+ throw error;
109
+ }
110
+ try {
111
+ const identity = await handle.stat();
112
+ leases.push({ path: leasePath, device: identity.dev, inode: identity.ino });
113
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid })}\n`, 'utf8');
114
+ await handle.sync();
115
+ }
116
+ finally {
117
+ await handle.close();
118
+ }
119
+ }
120
+ const snapshots = new Map();
121
+ let sourceInput = null;
122
+ for (const path of paths) {
123
+ const value = await snapshot(path, options.maximumInputBytes, label, options.signal);
124
+ if (path === source) {
125
+ if (!value)
126
+ throw new CliUsageError(`${label} source file disappeared before it could be read.`);
127
+ sourceInput = value.text;
128
+ }
129
+ if (path === destination && value && !options.force) {
130
+ throw new CliUsageError(`${label} output already exists; use --force to replace it or choose another path.`);
131
+ }
132
+ snapshots.set(path, value?.identity ?? null);
133
+ }
134
+ async function assertUnchanged() {
135
+ if (released)
136
+ throw new CliUsageError(`${label} file ownership has already been released.`);
137
+ options.signal?.throwIfAborted();
138
+ for (const lease of leases) {
139
+ if (!await ownsLease(lease))
140
+ throw new CliUsageError(`${label} file ownership changed; output was not published.`);
141
+ }
142
+ for (const [path, expected] of snapshots) {
143
+ const actual = (await snapshot(path, options.maximumInputBytes, label, options.signal))?.identity ?? null;
144
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
145
+ throw new CliUsageError(`${label} changed during execution; output was not published. Review the current files before resuming.`);
146
+ }
147
+ }
148
+ }
149
+ return Object.freeze({
150
+ sourceInput,
151
+ async publish(content) {
152
+ if (published || released)
153
+ throw new CliUsageError(`${label} file publication has already settled.`);
154
+ if (Buffer.byteLength(content, 'utf8') > options.maximumOutputBytes) {
155
+ throw new CliUsageError(`${label} output is limited to ${options.maximumOutputBytes} bytes.`);
156
+ }
157
+ await writePrivateFile(destination, content, { force: options.force, beforePublish: assertUnchanged });
158
+ published = true;
159
+ },
160
+ release,
161
+ });
162
+ }
163
+ catch (error) {
164
+ const unresolved = await release();
165
+ if (unresolved) {
166
+ throw new CliUsageError(`${label} file preparation failed and lease cleanup could not be verified. Inspect the selected directory before resuming.`);
167
+ }
168
+ throw error;
169
+ }
170
+ }
@@ -6,7 +6,9 @@ const LOOKALIKE_CALIBRATION_VERSION = 1;
6
6
  const MAX_LOOKALIKE_CALIBRATION_BYTES = 2 * 1024 * 1024;
7
7
  const MAX_LOOKALIKE_CALIBRATION_RECORDS = 5_000;
8
8
  const MIN_LOOKALIKE_CALIBRATION_SAMPLE = 20;
9
- const DISPOSITIONS = ['suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action'];
9
+ // Version 1 intentionally admits reviewed Case dispositions only. This local
10
+ // schema remains an independent compatibility decision from the live Case model.
11
+ const DISPOSITIONS = Object.freeze(['suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action']);
10
12
  function record(value) {
11
13
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
12
14
  }
@@ -22,7 +24,10 @@ function ratio(value, total) {
22
24
  return total ? Number((value / total).toFixed(4)) : 0;
23
25
  }
24
26
  function emptyCounts() {
25
- return { suspicious: 0, confirmed_abuse: 0, false_positive: 0, expected: 0, closed_no_action: 0 };
27
+ const counts = {};
28
+ for (const disposition of DISPOSITIONS)
29
+ counts[disposition] = 0;
30
+ return counts;
26
31
  }
27
32
  function buildLookalikeCalibration(raw, generatedAt = new Date().toISOString()) {
28
33
  if (Buffer.byteLength(raw, 'utf8') > MAX_LOOKALIKE_CALIBRATION_BYTES) {
@@ -111,8 +111,10 @@ export function buildCliLookupBrief(input, generatedAt = new Date().toISOString(
111
111
  const facts = [
112
112
  ['registration-state', 'Registration state', availability.state, availabilitySource.id],
113
113
  ['registrar', 'Registrar', record(preferred.registrar).name ?? preferred.registrar, preferredRegistrationSource.id],
114
- ['created', 'Created', record(preferred.lifecycle).createdIso ?? preferred.createdDateIso, preferredRegistrationSource.id],
115
- ['expires', 'Expires', record(preferred.lifecycle).expiryIso ?? preferred.expiryDateIso, preferredRegistrationSource.id],
114
+ ['created', 'Created', record(preferred.lifecycle).createdDateIso
115
+ ?? record(preferred.lifecycle).createdIso ?? preferred.createdDateIso, preferredRegistrationSource.id],
116
+ ['expires', 'Expires', record(preferred.lifecycle).expiryDateIso
117
+ ?? record(preferred.lifecycle).expiryIso ?? preferred.expiryDateIso, preferredRegistrationSource.id],
116
118
  ['name-servers', 'Name servers', joinedText(preferred.nameservers), preferredRegistrationSource.id],
117
119
  ['website-activity', 'Website activity', availability.activityStatus, httpSource.id],
118
120
  ['page-title', 'Page title', availability.pageTitle, httpSource.id],
@@ -2,7 +2,7 @@ import { formatTerminalLookup, safeTerminalValue } from "./formatters/terminal.m
2
2
  import { presentTerminalOutput, wrapTerminalOutput, } from "./terminal-presentation.mjs";
3
3
  const MIN_LOOKUP_BROWSER_HEIGHT = 12;
4
4
  const MAX_LOOKUP_BROWSER_HEIGHT = 80;
5
- const MAX_LOOKUP_BROWSER_PANELS = 9;
5
+ const MAX_LOOKUP_BROWSER_PANELS = 16;
6
6
  const MAX_LOOKUP_BROWSER_SEARCH_BYTES = 256;
7
7
  const MAX_LOOKUP_BROWSER_SEARCH_SCALARS = 80;
8
8
  const MAX_LOOKUP_BROWSER_SEARCH_MATCHES = 256;
@@ -1,4 +1,5 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
+ import { canReadInteractiveLine } from "./terminal-input.mjs";
2
3
  import { safeTerminalValue } from "./formatters/terminal.mjs";
3
4
  import { terminalPresentation, } from "./terminal-presentation.mjs";
4
5
  import { MIN_LOOKUP_BROWSER_HEIGHT, MAX_LOOKUP_BROWSER_PANELS, MAX_LOOKUP_BROWSER_SEARCH_BYTES, MAX_LOOKUP_BROWSER_SEARCH_MATCHES, MAX_LOOKUP_BROWSER_SEARCH_SCALARS, boundedBrowserDimensions, boundedSearchText, buildLookupBrowserHelpFrame, buildLookupBrowserFrame, buildLookupBrowserPanels, buildLookupBrowserSearchFrame, buildLookupCollectionFrame, findLookupBrowserMatches, renderedPanelLineOffset, renderLookupBrowser, terminalDisplayWidth, } from "./lookup-browser-view.mjs";
@@ -10,18 +11,10 @@ const MAX_PENDING_INPUT_LENGTH = 512;
10
11
  const MAX_LOOKUP_BROWSER_INPUT_CHUNK_BYTES = 2_048;
11
12
  const COMPLETE_CSI_RE = /^\u001b\[[0-?]*[ -/]*[@-~]/u;
12
13
  function canBrowseLookup(input, output, environment = process.env) {
13
- return input?.isTTY === true
14
- && output?.isTTY === true
15
- && typeof input.setRawMode === 'function'
16
- && typeof input.resume === 'function'
17
- && typeof input.pause === 'function'
18
- && typeof input.on === 'function'
19
- && typeof input.off === 'function'
20
- && typeof output.write === 'function'
21
- && !(Number.isFinite(output.columns) && Number(output.columns) < 40)
22
- && !(Number.isFinite(output.rows) && Number(output.rows) < MIN_LOOKUP_BROWSER_HEIGHT)
23
- && environment.TERM !== 'dumb'
24
- && !environment.CI;
14
+ return canReadInteractiveLine(input, output, environment)
15
+ && !(Number.isFinite(output?.columns) && Number(output?.columns) < 40)
16
+ && !(Number.isFinite(output?.rows) && Number(output?.rows) < MIN_LOOKUP_BROWSER_HEIGHT)
17
+ && environment.TERM !== 'dumb';
25
18
  }
26
19
  function runLookupBrowserSession(options) {
27
20
  const environment = options.environment || process.env;