@slicedearth/whoisleuth-cli 2.0.0 → 2.1.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 (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +17 -10
  37. package/docs/cli.md +5 -3
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/project-metadata.mjs +1 -1
  59. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  60. package/lib/registrar-standing-contract.mjs +300 -0
  61. package/lib/registrar-standing.mjs +269 -0
  62. package/lib/registry-insights.mjs +2 -8
  63. package/lib/risk-scoring.mjs +26 -10
  64. package/lib/sslbl-certificates.generated.mjs +138 -136
  65. package/lib/structured-data-identity.mjs +1 -3
  66. package/lib/technology-evidence-role.mjs +51 -0
  67. package/lib/threat-intelligence-runtime.mjs +3 -3
  68. package/lib/threat-intelligence-types.mjs +13 -0
  69. package/lib/threatfox-intelligence.mjs +1 -3
  70. package/lib/tls-intelligence.mjs +1 -2
  71. package/lib/urlhaus-intelligence.mjs +1 -3
  72. package/lib/urlscan-intelligence.mjs +1 -3
  73. package/lib/website-security-posture.mjs +1 -2
  74. package/lib/website-technology.mjs +72 -23
  75. package/lib/whois-common-formats.mjs +276 -0
  76. package/lib/whois-dialects.mjs +534 -0
  77. package/lib/whois-parser-support.mjs +305 -0
  78. package/lib/whois-parser.mjs +135 -0
  79. package/lib/whois.mjs +6 -1187
  80. package/package.json +3 -3
  81. package/packages/cases/case-evidence-model.mjs +9 -0
  82. package/packages/cases/case-migration-model.mjs +17 -11
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +227 -63
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  93. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  94. package/packages/workspace/brand-profile-model.mjs +1 -1
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
@@ -0,0 +1,132 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
3
+ import EXIT_CODES from "./exit-codes.mjs";
4
+ import { buildCliEvidenceExport, formatCliEvidenceExport } from "./export-evidence.mjs";
5
+ import { formatJsonDocument } from "./formatters/json.mjs";
6
+ import { formatLookupEvidenceHtml } from "./formatters/html.mjs";
7
+ import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
8
+ import { MAX_LOOKUP_RECONCILIATION_INPUT_BYTES, buildCliLookupReconciliation, formatCliLookupReconciliation, } from "./lookup-reconcile.mjs";
9
+ import { MAX_LOOKUP_TIMELINE_INPUT_BYTES, buildCliLookupTimeline, formatCliLookupTimeline, } from "./lookup-timeline.mjs";
10
+ import { MAX_RETAINED_ARTIFACT_DIFF_BYTES, buildCliRetainedArtifactDiff, formatCliRetainedArtifactDiff, } from "./retained-artifact-diff.mjs";
11
+ import { MAX_SAVED_LOOKUP_INPUT_BYTES } from "./saved-lookup.mjs";
12
+ import { HISTORY_INLINE_COMMANDS } from "./inline-command-families.mjs";
13
+ import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
14
+ async function runRetainedDiffCommand(args, dependencies, context) {
15
+ context.setFailureLabel('Retained artifact diff');
16
+ const readDiffInput = dependencies.readDiffInput
17
+ || ((source) => context.readInput(source, MAX_RETAINED_ARTIFACT_DIFF_BYTES, 'Retained diff input'));
18
+ let leftInput;
19
+ let rightInput;
20
+ try {
21
+ [leftInput, rightInput] = await Promise.all([
22
+ readDiffInput(args.leftSource),
23
+ readDiffInput(args.rightSource),
24
+ ]);
25
+ }
26
+ catch (error) {
27
+ if (error instanceof CliUsageError)
28
+ throw error;
29
+ throw new CliUsageError(`Could not read retained diff input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
30
+ }
31
+ const document = buildCliRetainedArtifactDiff(leftInput, rightInput, { leftSessionId: args.leftSessionId, rightSessionId: args.rightSessionId }, context.now());
32
+ if (!args.quiet) {
33
+ context.writeStdout(args.output === 'json'
34
+ ? formatJsonDocument(document)
35
+ : context.terminal(formatCliRetainedArtifactDiff(document), args.color));
36
+ }
37
+ return EXIT_CODES.SUCCESS;
38
+ }
39
+ async function runReconciliationCommand(args, dependencies, context) {
40
+ context.setFailureLabel('Lookup observation reconciliation');
41
+ const readReconciliationInput = dependencies.readDiffInput
42
+ || ((source) => context.readInput(source, MAX_SAVED_LOOKUP_INPUT_BYTES, 'Lookup reconciliation input'));
43
+ const inputs = [];
44
+ let totalBytes = 0;
45
+ try {
46
+ for (const source of args.sources) {
47
+ const input = await readReconciliationInput(source);
48
+ totalBytes += Buffer.byteLength(input, 'utf8');
49
+ if (totalBytes > MAX_LOOKUP_RECONCILIATION_INPUT_BYTES) {
50
+ throw new CliUsageError(`Lookup reconciliation input is limited to ${MAX_LOOKUP_RECONCILIATION_INPUT_BYTES} bytes in total.`);
51
+ }
52
+ inputs.push(input);
53
+ }
54
+ }
55
+ catch (error) {
56
+ if (error instanceof CliUsageError)
57
+ throw error;
58
+ throw new CliUsageError(`Could not read Lookup reconciliation input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
59
+ }
60
+ const document = buildCliLookupReconciliation(inputs, context.now());
61
+ if (!args.quiet) {
62
+ context.writeStdout(args.output === 'json'
63
+ ? formatJsonDocument(document)
64
+ : context.terminal(formatCliLookupReconciliation(document), args.color));
65
+ }
66
+ return EXIT_CODES.SUCCESS;
67
+ }
68
+ async function runTimelineCommand(args, dependencies, context) {
69
+ context.setFailureLabel('Lookup observation timeline');
70
+ const readTimelineInput = dependencies.readDiffInput
71
+ || ((source) => context.readInput(source, MAX_SAVED_LOOKUP_INPUT_BYTES, 'Lookup timeline input'));
72
+ const inputs = [];
73
+ let totalBytes = 0;
74
+ try {
75
+ for (const source of args.sources) {
76
+ const input = await readTimelineInput(source);
77
+ totalBytes += Buffer.byteLength(input, 'utf8');
78
+ if (totalBytes > MAX_LOOKUP_TIMELINE_INPUT_BYTES) {
79
+ throw new CliUsageError(`Lookup timeline input is limited to ${MAX_LOOKUP_TIMELINE_INPUT_BYTES} bytes in total.`);
80
+ }
81
+ inputs.push(input);
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (error instanceof CliUsageError)
86
+ throw error;
87
+ throw new CliUsageError(`Could not read Lookup timeline input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
88
+ }
89
+ const document = buildCliLookupTimeline(inputs, context.now());
90
+ if (!args.quiet) {
91
+ context.writeStdout(args.output === 'json'
92
+ ? formatJsonDocument(document)
93
+ : context.terminal(formatCliLookupTimeline(document), args.color));
94
+ }
95
+ return EXIT_CODES.SUCCESS;
96
+ }
97
+ async function runExportCommand(args, dependencies, context) {
98
+ context.setFailureLabel('Evidence export');
99
+ let input;
100
+ try {
101
+ input = dependencies.readExportInput
102
+ ? await dependencies.readExportInput(args.source)
103
+ : await context.readInput(args.source, MAX_SAVED_LOOKUP_INPUT_BYTES, 'Evidence export input');
104
+ }
105
+ catch (error) {
106
+ if (error instanceof CliUsageError)
107
+ throw error;
108
+ throw new CliUsageError(`Could not read evidence export input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
109
+ }
110
+ if (!input.trim())
111
+ throw new CliUsageError('export requires one lookup JSON file or a lookup document on stdin.');
112
+ const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
113
+ const evidenceModule = await loadEvidence();
114
+ const document = buildCliEvidenceExport(input, evidenceModule, context.now());
115
+ const output = args.format === 'markdown'
116
+ ? formatLookupEvidenceMarkdown(document, { includeAttribution: args.includeAttribution })
117
+ : args.format === 'html'
118
+ ? formatLookupEvidenceHtml(document, { includeAttribution: args.includeAttribution })
119
+ : formatCliEvidenceExport(document, args.compact);
120
+ context.writeStdout(output);
121
+ return EXIT_CODES.SUCCESS;
122
+ }
123
+ const HISTORY_COMMAND_HANDLERS = Object.freeze({
124
+ 'diff': runRetainedDiffCommand,
125
+ 'reconcile': runReconciliationCommand,
126
+ 'timeline': runTimelineCommand,
127
+ 'export': runExportCommand,
128
+ });
129
+ function runHistoryCommand(args, dependencies, context) {
130
+ return runDiscriminatedCommandHandler(HISTORY_COMMAND_HANDLERS, args, dependencies, context);
131
+ }
132
+ export { HISTORY_COMMAND_HANDLERS, runHistoryCommand };
@@ -0,0 +1,45 @@
1
+ const SUPPORT_INLINE_COMMANDS = Object.freeze([
2
+ 'completion',
3
+ 'commands',
4
+ 'manual',
5
+ 'doctor',
6
+ 'registry-support',
7
+ 'registry-doctor',
8
+ 'registry-cohort',
9
+ 'registry-scaffold',
10
+ 'risk-calibrate',
11
+ 'lookalike-calibrate',
12
+ ]);
13
+ const REVIEW_INLINE_COMMANDS = Object.freeze([
14
+ 'verify-artifact',
15
+ 'interchange-report',
16
+ 'source-report',
17
+ 'compare',
18
+ 'page-compare',
19
+ 'mail-review',
20
+ 'review-evidence',
21
+ 'brief',
22
+ 'case-pack',
23
+ ]);
24
+ const ASSURANCE_INLINE_COMMANDS = Object.freeze([
25
+ 'manifest',
26
+ 'map-observations',
27
+ 'oam-export',
28
+ 'domain-control',
29
+ 'assurance',
30
+ 'change-packet',
31
+ 'sharing-review',
32
+ 'ct-intake',
33
+ ]);
34
+ const WORKFLOW_INLINE_COMMANDS = Object.freeze([
35
+ 'monitor-once',
36
+ 'workflow-plan',
37
+ 'workflow-run',
38
+ ]);
39
+ const HISTORY_INLINE_COMMANDS = Object.freeze([
40
+ 'diff',
41
+ 'reconcile',
42
+ 'timeline',
43
+ 'export',
44
+ ]);
45
+ export { ASSURANCE_INLINE_COMMANDS, HISTORY_INLINE_COMMANDS, REVIEW_INLINE_COMMANDS, SUPPORT_INLINE_COMMANDS, WORKFLOW_INLINE_COMMANDS, };
@@ -0,0 +1,55 @@
1
+ import { CLI_COMMANDS, commandDefinition } from "./command-reference.mjs";
2
+ import { ASSURANCE_INLINE_COMMANDS, HISTORY_INLINE_COMMANDS, REVIEW_INLINE_COMMANDS, SUPPORT_INLINE_COMMANDS, WORKFLOW_INLINE_COMMANDS, } from "./inline-command-families.mjs";
3
+ const FAMILY_COMMANDS = Object.freeze([
4
+ Object.freeze({ family: 'support', commands: SUPPORT_INLINE_COMMANDS }),
5
+ Object.freeze({ family: 'review', commands: REVIEW_INLINE_COMMANDS }),
6
+ Object.freeze({ family: 'assurance', commands: ASSURANCE_INLINE_COMMANDS }),
7
+ Object.freeze({ family: 'workflow', commands: WORKFLOW_INLINE_COMMANDS }),
8
+ Object.freeze({ family: 'history', commands: HISTORY_INLINE_COMMANDS }),
9
+ ]);
10
+ function buildInlineCommandOwnership() {
11
+ const ownership = new Map();
12
+ for (const { family, commands } of FAMILY_COMMANDS) {
13
+ for (const command of commands) {
14
+ if (ownership.has(command))
15
+ throw new Error(`Inline CLI command ${command} has more than one command-family owner.`);
16
+ ownership.set(command, family);
17
+ }
18
+ }
19
+ return ownership;
20
+ }
21
+ const INLINE_COMMAND_OWNERSHIP = buildInlineCommandOwnership();
22
+ const INLINE_CLI_COMMANDS = Object.freeze(CLI_COMMANDS.filter((command) => commandDefinition(command).execution.handlerOwner === 'inline'));
23
+ function validateInlineCommandOwnership() {
24
+ const missing = INLINE_CLI_COMMANDS.filter((command) => !INLINE_COMMAND_OWNERSHIP.has(command));
25
+ const unexpected = [...INLINE_COMMAND_OWNERSHIP.keys()].filter((command) => !INLINE_CLI_COMMANDS.includes(command));
26
+ if (missing.length || unexpected.length) {
27
+ throw new Error(`Inline CLI command-family ownership is inconsistent (missing: ${missing.join(', ') || 'none'}; unexpected: ${unexpected.join(', ') || 'none'}).`);
28
+ }
29
+ }
30
+ validateInlineCommandOwnership();
31
+ async function runInlineCommand(args, dependencies, context) {
32
+ const family = INLINE_COMMAND_OWNERSHIP.get(args.action);
33
+ if (family === 'support') {
34
+ const { runSupportCommand } = await import("./support-command-runner.mjs");
35
+ return runSupportCommand(args, dependencies, context);
36
+ }
37
+ if (family === 'review') {
38
+ const { runReviewCommand } = await import("./review-command-runner.mjs");
39
+ return runReviewCommand(args, dependencies, context);
40
+ }
41
+ if (family === 'assurance') {
42
+ const { runAssuranceCommand } = await import("./assurance-command-runner.mjs");
43
+ return runAssuranceCommand(args, dependencies, context);
44
+ }
45
+ if (family === 'workflow') {
46
+ const { runWorkflowCommand } = await import("./workflow-command-runner.mjs");
47
+ return runWorkflowCommand(args, dependencies, context);
48
+ }
49
+ if (family === 'history') {
50
+ const { runHistoryCommand } = await import("./history-command-runner.mjs");
51
+ return runHistoryCommand(args, dependencies, context);
52
+ }
53
+ throw new Error('No inline CLI command-family route is registered for the parsed command.');
54
+ }
55
+ export { FAMILY_COMMANDS, INLINE_CLI_COMMANDS, runInlineCommand, };
@@ -13,7 +13,10 @@ const RECIPES = Object.freeze({
13
13
  subjectRequirement: 'domain',
14
14
  objective: 'Collect and preserve separately attributed registration, DNS, HTTP, TLS, page, and network-context evidence.',
15
15
  runnable: true,
16
- limitations: Object.freeze(['Collection remains analyst-triggered and source limitations remain explicit.']),
16
+ limitations: Object.freeze([
17
+ 'Collection remains analyst-triggered and source limitations remain explicit.',
18
+ 'Disposition, reviewed response actions, monitoring, and closure continue in the browser-local Case workspace; this CLI recipe does not submit reports.',
19
+ ]),
17
20
  steps: (domain) => Object.freeze([
18
21
  step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
19
22
  step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
@@ -26,7 +29,10 @@ const RECIPES = Object.freeze({
26
29
  subjectRequirement: 'brand_or_domain',
27
30
  objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
28
31
  runnable: true,
29
- limitations: Object.freeze(['Candidate generation does not establish registration, control, intent, or maliciousness.']),
32
+ limitations: Object.freeze([
33
+ 'Candidate generation does not establish registration, control, intent, or maliciousness.',
34
+ 'Official-reference collection and page comparison require analyst-selected saved evidence; use page-compare after retaining the reference and candidate observations.',
35
+ ]),
30
36
  steps: (subject) => Object.freeze([
31
37
  step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
32
38
  step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
@@ -238,6 +244,7 @@ export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue
238
244
  execution: 'plan_only',
239
245
  steps,
240
246
  limitations: Object.freeze([
247
+ ...recipe.limitations,
241
248
  'This document is a fixed plan. It does not execute commands, expand placeholders, make requests, read files, change cases, or submit reports.',
242
249
  'Network steps require deliberate execution and disclose the selected target to the sources described by that command.',
243
250
  'Analyst-selection steps require reviewed local artefacts; placeholders are never interpreted as file paths by this planner.',
@@ -1,11 +1,15 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { buildInvestigationPlan, isRunnableInvestigationRecipe, } from "./investigation-plan.mjs";
3
3
  import { CliUsageError } from "./errors.mjs";
4
+ import { hasUnsafeCliText } from "./errors.mjs";
4
5
  import EXIT_CODES from "./exit-codes.mjs";
5
6
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
6
7
  export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
7
- export const CLI_INVESTIGATION_RUN_VERSION = 1;
8
+ export const CLI_INVESTIGATION_RUN_VERSION = 2;
8
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;
12
+ const PLACEHOLDER_PATTERN = /^<[^>]+>$/u;
9
13
  function boundedResult(value) {
10
14
  if (Buffer.byteLength(value, 'utf8') > MAX_INVESTIGATION_RUN_BYTES) {
11
15
  throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
@@ -31,9 +35,95 @@ function sameArguments(left, right) {
31
35
  && left.length === right.length
32
36
  && left.every((value, index) => typeof value === 'string' && value === right[index]);
33
37
  }
38
+ function selectedArguments(step, values) {
39
+ let selectionIndex = 0;
40
+ return Object.freeze(step.arguments.map((argument) => {
41
+ if (!PLACEHOLDER_PATTERN.test(argument))
42
+ return argument;
43
+ const selected = values[selectionIndex];
44
+ selectionIndex += 1;
45
+ return selected ?? argument;
46
+ }));
47
+ }
48
+ function selectedStep(step, values) {
49
+ return Object.freeze({ ...step, arguments: selectedArguments(step, values) });
50
+ }
51
+ function placeholderCount(step) {
52
+ return step.arguments.filter((argument) => PLACEHOLDER_PATTERN.test(argument)).length;
53
+ }
54
+ function validateSelectionValue(value) {
55
+ return typeof value === 'string'
56
+ && value.length > 0
57
+ && value.length <= MAX_INVESTIGATION_RUN_SELECTION_LENGTH
58
+ && Boolean(value.trim())
59
+ && !value.startsWith('-')
60
+ && !hasUnsafeCliText(value);
61
+ }
62
+ function normalizeRetainedSelections(plan, value, label) {
63
+ if (!Array.isArray(value) || value.length > MAX_INVESTIGATION_RUN_SELECTIONS) {
64
+ throw new CliUsageError(`${label} must contain at most ${MAX_INVESTIGATION_RUN_SELECTIONS} bounded analyst selections.`);
65
+ }
66
+ const plannedById = new Map(plan.steps.map((step) => [step.id, step]));
67
+ const seen = new Set();
68
+ const retained = [];
69
+ for (const itemValue of value) {
70
+ if (!itemValue || typeof itemValue !== 'object' || Array.isArray(itemValue)) {
71
+ throw new CliUsageError(`${label} contains a malformed analyst selection.`);
72
+ }
73
+ const item = itemValue;
74
+ if (Object.keys(item).length !== 2 || !Object.hasOwn(item, 'stepId') || !Object.hasOwn(item, 'values')) {
75
+ throw new CliUsageError(`${label} contains a malformed analyst selection.`);
76
+ }
77
+ const stepId = typeof item.stepId === 'string' ? item.stepId : '';
78
+ const planned = plannedById.get(stepId);
79
+ if (!planned || seen.has(stepId) || placeholderCount(planned) === 0 || !Array.isArray(item.values)) {
80
+ throw new CliUsageError(`${label} does not match the installed fixed recipe.`);
81
+ }
82
+ if (item.values.length === 0 || item.values.length > placeholderCount(planned)
83
+ || item.values.some((selection) => !validateSelectionValue(selection))) {
84
+ throw new CliUsageError(`${label} contains an invalid value for step ${stepId}.`);
85
+ }
86
+ seen.add(stepId);
87
+ retained.push(Object.freeze({ stepId, values: Object.freeze([...item.values]) }));
88
+ }
89
+ return plan.steps.flatMap((step) => {
90
+ const selection = retained.find((item) => item.stepId === step.id);
91
+ return selection ? [selection] : [];
92
+ });
93
+ }
94
+ function normalizeInvocationSelections(plan, selections) {
95
+ if (!Array.isArray(selections) || selections.length > MAX_INVESTIGATION_RUN_SELECTIONS) {
96
+ throw new CliUsageError(`workflow-run accepts at most ${MAX_INVESTIGATION_RUN_SELECTIONS} analyst selections.`);
97
+ }
98
+ const grouped = new Map();
99
+ for (const item of selections) {
100
+ if (!item || typeof item !== 'object' || Array.isArray(item)
101
+ || typeof item.stepId !== 'string' || !validateSelectionValue(item.value)) {
102
+ throw new CliUsageError('workflow-run selections must use bounded <step-id>=<path-or-value> entries whose values do not start with a hyphen.');
103
+ }
104
+ const values = grouped.get(item.stepId) ?? [];
105
+ values.push(item.value);
106
+ grouped.set(item.stepId, values);
107
+ }
108
+ return normalizeRetainedSelections(plan, [...grouped].map(([stepId, values]) => ({ stepId, values })), 'workflow-run selections');
109
+ }
110
+ function mergeSelections(plan, retained, supplied, completed) {
111
+ const merged = new Map(retained.map((item) => [item.stepId, item.values]));
112
+ for (const item of supplied) {
113
+ 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))) {
115
+ throw new CliUsageError(`workflow-run cannot change analyst selections for completed step ${item.stepId}.`);
116
+ }
117
+ merged.set(item.stepId, item.values);
118
+ }
119
+ return plan.steps.flatMap((step) => {
120
+ const values = merged.get(step.id);
121
+ return values ? [Object.freeze({ stepId: step.id, values: Object.freeze([...values]) })] : [];
122
+ });
123
+ }
34
124
  function parseResumeState(input, plan) {
35
125
  if (!input)
36
- return [];
126
+ return Object.freeze({ completed: Object.freeze([]), selections: Object.freeze([]) });
37
127
  if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
38
128
  throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
39
129
  const normalizedInput = input.replace(/^\uFEFF/u, '');
@@ -54,15 +144,25 @@ function parseResumeState(input, plan) {
54
144
  throw new CliUsageError('Investigation resume state must be valid JSON.');
55
145
  }
56
146
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
57
- if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
147
+ if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA
148
+ || (root.version !== 1 && root.version !== CLI_INVESTIGATION_RUN_VERSION)
149
+ || root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
58
150
  throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
59
151
  }
152
+ const selections = root.version === 1
153
+ ? Object.freeze([])
154
+ : Object.freeze(normalizeRetainedSelections(plan, root.selections, 'Investigation resume state'));
155
+ if (root.version === 1 && root.selections !== undefined) {
156
+ throw new CliUsageError('Investigation resume state version 1 cannot contain analyst selections.');
157
+ }
158
+ const selectionsByStep = new Map(selections.map((item) => [item.stepId, item.values]));
60
159
  if (root.completedSteps.length > plan.steps.length) {
61
160
  throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
62
161
  }
63
162
  const completed = [];
64
163
  for (const [index, value] of root.completedSteps.entries()) {
65
- const planned = plan.steps[index];
164
+ const baseStep = plan.steps[index];
165
+ const planned = baseStep ? selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? []) : null;
66
166
  if (!planned || !value || typeof value !== 'object' || Array.isArray(value)) {
67
167
  throw new CliUsageError('Investigation resume state contains a malformed or out-of-order step.');
68
168
  }
@@ -93,7 +193,7 @@ function parseResumeState(input, plan) {
93
193
  result: item.result,
94
194
  }));
95
195
  }
96
- return completed;
196
+ return Object.freeze({ completed: Object.freeze(completed), selections });
97
197
  }
98
198
  export async function runInvestigationRecipe(recipe, subjectValue, options) {
99
199
  if (!isRunnableInvestigationRecipe(recipe)) {
@@ -101,11 +201,15 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
101
201
  }
102
202
  const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
103
203
  const prior = parseResumeState(options.resumeInput, plan);
104
- const completed = [...prior];
204
+ const suppliedSelections = normalizeInvocationSelections(plan, options.selections ?? []);
205
+ const selections = mergeSelections(plan, prior.selections, suppliedSelections, prior.completed);
206
+ const selectionsByStep = new Map(selections.map((item) => [item.stepId, item.values]));
207
+ const completed = [...prior.completed];
105
208
  let state = 'complete';
106
209
  let currentStep = null;
107
- for (const step of plan.steps) {
210
+ for (const baseStep of plan.steps) {
108
211
  options.signal?.throwIfAborted();
212
+ const step = selectedStep(baseStep, selectionsByStep.get(baseStep.id) ?? []);
109
213
  if (completed.some((item) => item.id === step.id))
110
214
  continue;
111
215
  currentStep = step;
@@ -147,12 +251,13 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
147
251
  subject: plan.subject,
148
252
  state,
149
253
  networkApprovedForThisRun: options.approveNetwork,
254
+ selections: Object.freeze(selections),
150
255
  completedSteps: Object.freeze(completed),
151
256
  currentStep,
152
257
  limitations: Object.freeze([
153
258
  'Only commands and arguments from the installed fixed recipe can execute; no shell, script, arbitrary command, or enforcement action is accepted.',
154
- 'Network steps run only with --approve-network for the current invocation. Analyst-selection placeholders always pause and are never interpreted as paths or values.',
155
- 'A resume file is a local checkpoint, not proof that prior evidence remains current or that a human reviewed each stored result.',
259
+ '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.',
260
+ '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.',
156
261
  ]),
157
262
  });
158
263
  }