@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.
- package/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|| (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
`
|
|
270
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/cli/lookup-brief.mjs
CHANGED
|
@@ -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).
|
|
115
|
-
|
|
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 =
|
|
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;
|
package/cli/lookup-browser.mjs
CHANGED
|
@@ -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
|
|
14
|
-
&& output?.
|
|
15
|
-
&&
|
|
16
|
-
&&
|
|
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;
|