rea-agents 1.1.0 → 1.3.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 (103) hide show
  1. package/README.md +94 -28
  2. package/bridge/hopper_bridge.py +161 -12
  3. package/dist/application/AnalysisSnapshotCache.js +131 -0
  4. package/dist/application/AnalysisSnapshotFiles.js +31 -0
  5. package/dist/application/ArtifactInventory.js +84 -44
  6. package/dist/application/AuthorizedArtifactInventory.js +16 -0
  7. package/dist/application/BinarySession.js +66 -36
  8. package/dist/application/BinarySessionPort.js +1 -0
  9. package/dist/application/BoundedJsonFiles.js +109 -0
  10. package/dist/application/CommandShimReplay.js +136 -0
  11. package/dist/application/CrossVersionInventory.js +98 -0
  12. package/dist/application/CrossVersionInvestigation.js +295 -0
  13. package/dist/application/DirectAnalysis.js +115 -22
  14. package/dist/application/Doctor.js +30 -1
  15. package/dist/application/EvidenceBundleFiles.js +16 -101
  16. package/dist/application/EvidenceLedger.js +3 -2
  17. package/dist/application/InvestigationProviders.js +48 -0
  18. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  19. package/dist/application/LinuxHopper.js +72 -48
  20. package/dist/application/ProcessCaptureAuthority.js +25 -0
  21. package/dist/application/ProcessCaptureCapability.js +23 -0
  22. package/dist/application/ProcessCaptureError.js +17 -0
  23. package/dist/application/ProcessCaptureLifecycle.js +287 -0
  24. package/dist/application/ProcessCheckpoints.js +129 -0
  25. package/dist/application/ProcessCli.js +128 -0
  26. package/dist/application/ProcessEvidence.js +38 -0
  27. package/dist/application/ProcessHarness.js +252 -272
  28. package/dist/application/ProcessNormalization.js +10 -1
  29. package/dist/application/ProcessOwnership.js +39 -1
  30. package/dist/application/ProcessSampling.js +39 -23
  31. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  32. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  33. package/dist/application/Setup.js +44 -96
  34. package/dist/application/SetupClients.js +19 -0
  35. package/dist/application/SetupInstallFailure.js +53 -0
  36. package/dist/application/SetupPlan.js +31 -0
  37. package/dist/application/SetupSkill.js +44 -0
  38. package/dist/application/TerminalRenderer.js +92 -0
  39. package/dist/application/Uninstall.js +33 -23
  40. package/dist/application/UnknownEvidence.js +33 -0
  41. package/dist/application/Upgrade.js +1 -1
  42. package/dist/application/runtime.js +2 -2
  43. package/dist/artifacts/ArtifactProvider.js +20 -11
  44. package/dist/artifacts/ArtifactReader.js +3 -1
  45. package/dist/artifacts/AsarArtifactReader.js +8 -1
  46. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  47. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  48. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  49. package/dist/artifacts/ZipArtifactReader.js +1 -0
  50. package/dist/cli.js +57 -32
  51. package/dist/cliEvidenceCommands.js +11 -12
  52. package/dist/cliInvestigationCommands.js +86 -0
  53. package/dist/cliOutput.js +41 -0
  54. package/dist/cliProcessCommands.js +29 -0
  55. package/dist/config.js +41 -24
  56. package/dist/contracts/artifactToolContracts.js +1 -0
  57. package/dist/contracts/investigationExamples.js +3 -3
  58. package/dist/contracts/processCaptureExample.js +50 -7
  59. package/dist/contracts/promptContracts.js +256 -0
  60. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  61. package/dist/contracts/toolContractTypes.js +1 -0
  62. package/dist/contracts/toolContracts.js +13 -8
  63. package/dist/contracts/toolOutputSchemas.js +33 -4
  64. package/dist/domain/analysisSnapshot.js +149 -0
  65. package/dist/domain/changedBehavior.js +32 -9
  66. package/dist/domain/errors.js +186 -39
  67. package/dist/domain/evidence.js +4 -2
  68. package/dist/domain/evidenceBundle.js +28 -0
  69. package/dist/domain/hopperStartupFailure.js +55 -0
  70. package/dist/domain/investigationWorkspace.js +214 -0
  71. package/dist/domain/jsonValue.js +2 -0
  72. package/dist/domain/nativeInspection.js +3 -2
  73. package/dist/domain/processCapture.js +100 -293
  74. package/dist/domain/processCaptureValidation.js +124 -0
  75. package/dist/domain/processComparison.js +177 -32
  76. package/dist/domain/processScenario.js +370 -0
  77. package/dist/domain/reconstructionVerification.js +3 -3
  78. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  79. package/dist/hopper/BridgeLauncher.js +47 -20
  80. package/dist/hopper/HopperClient.js +17 -2
  81. package/dist/hopper/HopperProvider.js +1 -0
  82. package/dist/identity.js +1 -0
  83. package/dist/main.js +47 -24
  84. package/dist/server/createServer.js +2 -0
  85. package/dist/server/promptCompletion.js +144 -0
  86. package/dist/server/registerEnhancedTools.js +2 -6
  87. package/dist/server/registerEvidenceTools.js +2 -8
  88. package/dist/server/registerFunctionComparisonTool.js +1 -1
  89. package/dist/server/registerInvestigationTools.js +31 -11
  90. package/dist/server/registerOfficialTools.js +6 -13
  91. package/dist/server/registerProcessComparisonTool.js +28 -14
  92. package/dist/server/registerPrompts.js +67 -0
  93. package/dist/server/registerSessionStatusTool.js +11 -0
  94. package/dist/server/registerSessionTools.js +55 -43
  95. package/dist/server/sessionEvidence.js +2 -8
  96. package/dist/server/sessionToolPolicies.js +2 -48
  97. package/dist/server/toolResult.js +7 -3
  98. package/install.sh +11 -11
  99. package/package.json +9 -3
  100. package/scripts/hopper-demo-x11.py +349 -0
  101. package/scripts/prepare-node-pty.mjs +35 -0
  102. package/scripts/rea.mjs +6 -3
  103. package/skills/rea-analysis/SKILL.md +27 -5
@@ -3,6 +3,12 @@ import { EnhancedTools } from "./EnhancedTools.js";
3
3
  import { createBinarySession } from "./runtime.js";
4
4
  import { silentLogger } from "../logger.js";
5
5
  import { createEvidence } from "../domain/evidence.js";
6
+ import { EvidenceIntegrityError, projectAnalysisError, } from "../domain/errors.js";
7
+ import { access } from "node:fs/promises";
8
+ import { readAnalysisSnapshot, writeAnalysisSnapshot, } from "./AnalysisSnapshotFiles.js";
9
+ import { parseBinaryTarget } from "../domain/binaryTarget.js";
10
+ import { snapshotEvidenceForQuery, snapshotMatchesTarget, } from "../domain/analysisSnapshot.js";
11
+ import { err, ok } from "../domain/result.js";
6
12
  const WORKFLOW_PROVIDER = {
7
13
  id: "rea-workflow",
8
14
  name: "REA composed investigation workflow",
@@ -13,14 +19,17 @@ const WORKFLOW_PROVIDER = {
13
19
  * Unlike MCP mode, every CLI invocation is intentionally isolated and does not
14
20
  * retain a target or bridge resources for a subsequent command.
15
21
  */
16
- export const runDirectAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
22
+ export const runDirectAnalysis = async (path, tool, arguments_, options = {}) => runAnalysis(path, tool, arguments_, {
23
+ logger: options.logger ?? silentLogger,
24
+ snapshotPath: options.snapshotPath,
25
+ });
17
26
  /** Execute one provider-native semantic operation with atomic provenance. */
18
- export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
27
+ export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined });
19
28
  /** Describe configured providers without opening a target or launching Hopper. */
20
29
  export const runSessionStatus = async (logger = silentLogger) => {
21
30
  const config = parseConfig(process.env);
22
31
  if (!config.ok)
23
- return { error: config.error._tag, message: config.error.message };
32
+ return cliError(config.error);
24
33
  const session = createBinarySession(config.value, logger);
25
34
  try {
26
35
  return session.status();
@@ -29,21 +38,42 @@ export const runSessionStatus = async (logger = silentLogger) => {
29
38
  await session.close();
30
39
  }
31
40
  };
32
- const runAnalysis = async (path, tool, arguments_, logger) => {
41
+ const runAnalysis = async (path, tool, arguments_, options) => {
42
+ const { logger, snapshotPath } = options;
33
43
  const config = parseConfig(process.env);
34
44
  if (!config.ok)
35
- return { error: config.error._tag, message: config.error.message };
45
+ return cliError(config.error);
46
+ const unavailable = snapshotUnavailable(snapshotPath, config.value.hopperLoaderArgs);
47
+ if (unavailable !== undefined)
48
+ return cliError(unavailable);
36
49
  const session = createBinarySession(config.value, logger);
37
50
  try {
38
- const opened = await session.open(path);
51
+ const prepared = await prepareSnapshot({
52
+ path,
53
+ tool,
54
+ arguments: arguments_,
55
+ snapshotPath,
56
+ policy: config.value.analysisSnapshotFilePolicy,
57
+ provider: replayProviderFor(session, tool),
58
+ });
59
+ if (!prepared.ok)
60
+ return cliError(prepared.error);
61
+ if (prepared.value.evidence !== undefined)
62
+ return prepared.value.evidence;
63
+ const { snapshot } = prepared.value;
64
+ const opened = await session.open(path, snapshot === undefined ? {} : { snapshot });
39
65
  if (!opened.ok)
40
- return { error: opened.error._tag, message: opened.error.message };
66
+ return cliError(opened.error);
67
+ let output;
68
+ let evidence;
41
69
  if (tool === "binary_overview" ||
42
70
  tool === "analyze_function" ||
43
71
  tool === "trace_feature") {
44
72
  const result = await new EnhancedTools(session).execute(tool, arguments_);
45
- return result.ok
46
- ? createEvidence(opened.value, tool === "analyze_function"
73
+ if (!result.ok)
74
+ output = cliError(result.error);
75
+ else {
76
+ evidence = createEvidence(opened.value, tool === "analyze_function"
47
77
  ? session.providerIdentity(tool)
48
78
  : WORKFLOW_PROVIDER, {
49
79
  operation: tool,
@@ -51,22 +81,85 @@ const runAnalysis = async (path, tool, arguments_, logger) => {
51
81
  result: result.value,
52
82
  confidence: "derived",
53
83
  limitations: ["Derived by an REA composed workflow."],
54
- })
55
- : { error: result.error._tag, message: result.error.message };
84
+ });
85
+ output = evidence;
86
+ }
56
87
  }
57
- const result = await session.execute(tool, arguments_);
58
- return result.ok
59
- ? createEvidence(result.value.subject ?? opened.value, result.value.provider, {
60
- operation: tool,
61
- parameters: arguments_,
62
- result: result.value.result,
63
- rawResult: result.value.rawResult,
64
- limitations: result.value.limitations,
65
- locations: result.value.locations,
66
- })
67
- : { error: result.error._tag, message: result.error.message };
88
+ else {
89
+ const result = await session.execute(tool, arguments_);
90
+ if (!result.ok)
91
+ output = cliError(result.error);
92
+ else {
93
+ evidence = createEvidence(result.value.subject ?? opened.value, result.value.provider, {
94
+ operation: tool,
95
+ parameters: arguments_,
96
+ result: result.value.result,
97
+ rawResult: result.value.rawResult,
98
+ limitations: result.value.limitations,
99
+ locations: result.value.locations,
100
+ });
101
+ output = evidence;
102
+ }
103
+ }
104
+ if (evidence !== undefined)
105
+ session.recordEvidence(evidence);
106
+ if (snapshotPath !== undefined && evidence !== undefined) {
107
+ const snapshot = session.exportAnalysisSnapshot();
108
+ if (!snapshot.ok)
109
+ return cliError(snapshot.error);
110
+ const written = await writeAnalysisSnapshot(snapshot.value, snapshotPath, true, config.value.analysisSnapshotFilePolicy);
111
+ if (!written.ok)
112
+ return cliError(written.error);
113
+ }
114
+ return output;
68
115
  }
69
116
  finally {
70
117
  await session.close();
71
118
  }
72
119
  };
120
+ const prepareSnapshot = async (options) => {
121
+ const { path, tool, arguments: arguments_, snapshotPath, policy, provider, } = options;
122
+ if (snapshotPath === undefined || !(await fileExists(snapshotPath)))
123
+ return ok({});
124
+ const loaded = await readAnalysisSnapshot(snapshotPath, policy);
125
+ if (!loaded.ok)
126
+ return loaded;
127
+ const target = await parseBinaryTarget(path);
128
+ if (!target.ok)
129
+ return target;
130
+ if (!snapshotMatchesTarget(loaded.value.target, target.value))
131
+ return err(new EvidenceIntegrityError("Analysis snapshot target does not match the requested binary"));
132
+ const evidence = snapshotEvidenceForQuery(loaded.value, {
133
+ target: target.value,
134
+ operation: tool,
135
+ parameters: arguments_,
136
+ provider,
137
+ });
138
+ return ok(evidence === undefined
139
+ ? { snapshot: loaded.value }
140
+ : { snapshot: loaded.value, evidence });
141
+ };
142
+ const snapshotUnavailable = (snapshotPath, loaderArgs) => snapshotPath !== undefined && loaderArgs.length > 0
143
+ ? new EvidenceIntegrityError("Analysis snapshots are unavailable with custom Hopper loader arguments")
144
+ : undefined;
145
+ const replayProviderFor = (session, tool) => tool === "binary_overview" || tool === "trace_feature"
146
+ ? WORKFLOW_PROVIDER
147
+ : session.providerIdentity(tool);
148
+ const fileExists = async (path) => {
149
+ try {
150
+ await access(path);
151
+ return true;
152
+ }
153
+ catch (cause) {
154
+ if (typeof cause === "object" &&
155
+ cause !== null &&
156
+ "code" in cause &&
157
+ cause.code === "ENOENT")
158
+ return false;
159
+ throw cause;
160
+ }
161
+ };
162
+ const cliError = (error) => ({
163
+ error: "Analysis failed",
164
+ ...projectAnalysisError(error),
165
+ });
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
8
8
  import { parseBinaryTarget } from "../domain/binaryTarget.js";
9
9
  import { supportsNodeVersion } from "../domain/runtimeVersion.js";
10
10
  import { probeHomebrew } from "./homebrew.js";
11
- import { linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
11
+ import { linuxHopperBinarySupported, linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
12
12
  const execFileAsync = promisify(execFile);
13
13
  const DEFAULT_HOPPER = "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
14
14
  const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
@@ -53,6 +53,16 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
53
53
  remediation: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
54
54
  classification: "missing_analysis_engine",
55
55
  }));
56
+ if (host.platform === "linux" && hopperPath !== undefined)
57
+ checks.push(check("hopper-version", await host.supportedLinuxHopper(hopperPath), hopperPath, {
58
+ remediation: unsupportedHopperRemediation(host.configuredHopperPath === hopperPath),
59
+ classification: "config_drift",
60
+ }));
61
+ if (host.platform === "linux" && hopperPath !== undefined)
62
+ checks.push(check("hopper-demo-runtime", await host.linuxDemoRuntimeReady(), undefined, {
63
+ remediation: "Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.",
64
+ classification: "missing_dependency",
65
+ }));
56
66
  if (target !== undefined)
57
67
  checks.push(check("target", await host.validTarget(target), target, {
58
68
  remediation: "Supply a readable local app or program path.",
@@ -64,6 +74,9 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
64
74
  checks,
65
75
  };
66
76
  };
77
+ const unsupportedHopperRemediation = (configured) => configured
78
+ ? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
79
+ : "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
67
80
  /** Create diagnostics backed by the current process and host commands. */
68
81
  export const systemDoctorHost = () => ({
69
82
  platform: process.platform,
@@ -97,6 +110,22 @@ export const systemDoctorHost = () => ({
97
110
  return false;
98
111
  }
99
112
  },
113
+ async linuxDemoRuntimeReady() {
114
+ try {
115
+ await access("/usr/bin/Xvfb", constants.X_OK);
116
+ await access("/usr/bin/xauth", constants.X_OK);
117
+ await access("/usr/bin/python3", constants.X_OK);
118
+ await execFileAsync("/usr/bin/python3", [
119
+ "-c",
120
+ "import ctypes; ctypes.CDLL('libX11.so.6'); ctypes.CDLL('libXtst.so.6')",
121
+ ]);
122
+ return true;
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ },
128
+ supportedLinuxHopper: linuxHopperBinarySupported,
100
129
  async brewHopperPath() {
101
130
  return probeHomebrew(async (command) => {
102
131
  try {
@@ -1,46 +1,29 @@
1
- import { lstat, readFile, realpath } from "node:fs/promises";
2
- import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
- import writeFileAtomic from "write-file-atomic";
4
1
  import { parseEvidenceBundle, serializeEvidenceBundle, } from "../domain/evidenceBundle.js";
5
2
  import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
6
3
  import { err, ok } from "../domain/result.js";
4
+ import { LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessCapture, } from "../domain/processCapture.js";
5
+ import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
7
6
  /** Read and validate a bounded evidence bundle inside an approved root. */
8
7
  export const readEvidenceBundle = async (path, policy) => {
9
8
  if (policy.roots.length === 0)
10
9
  return err(new EvidenceFileError("read", "disabled"));
10
+ const loaded = await readBoundedJson(path, policy);
11
+ if (!loaded.ok)
12
+ return loaded;
11
13
  try {
12
- const canonicalPath = await realpath(resolve(path));
13
- if (!(await isApproved(canonicalPath, policy.roots)))
14
- return err(new EvidenceFileError("read", "outside-root"));
15
- const stats = await lstat(canonicalPath);
16
- if (!stats.isFile())
17
- return err(new EvidenceFileError("read", "not-file"));
18
- if (stats.size > policy.maxBytes)
19
- return err(new EvidenceFileError("read", "too-large"));
20
- const encoded = await readFile(canonicalPath);
21
- if (encoded.byteLength > policy.maxBytes)
22
- return err(new EvidenceFileError("read", "too-large"));
23
- let decoded;
24
- try {
25
- decoded = JSON.parse(encoded.toString("utf8"));
26
- }
27
- catch (cause) {
28
- return err(new EvidenceFileError("read", "invalid-json", { cause }));
29
- }
30
- const limits = validateJsonLimits(decoded, policy);
31
- if (!limits.ok)
32
- return limits;
33
- try {
34
- return ok(parseEvidenceBundle(decoded));
35
- }
36
- catch (cause) {
37
- return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
38
- cause,
39
- }));
14
+ const bundle = parseEvidenceBundle(loaded.value);
15
+ for (const record of bundle.records) {
16
+ if (record.predicate_type === "rea.process-capture/v3")
17
+ throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
18
+ if (record.predicate_type === "rea.process-capture/v4")
19
+ parseProcessCapture(record.normalized_result);
40
20
  }
21
+ return ok(bundle);
41
22
  }
42
23
  catch (cause) {
43
- return err(new EvidenceFileError("read", "io", { cause }));
24
+ return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
25
+ cause,
26
+ }));
44
27
  }
45
28
  };
46
29
  /** Atomically write deterministic evidence JSON inside an approved root. */
@@ -56,73 +39,5 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
56
39
  cause,
57
40
  }));
58
41
  }
59
- const bytes = Buffer.byteLength(encoded, "utf8");
60
- if (bytes > policy.maxBytes)
61
- return err(new EvidenceFileError("write", "too-large"));
62
- try {
63
- const requestedPath = resolve(path);
64
- const canonicalParent = await realpath(dirname(requestedPath));
65
- const destination = resolve(canonicalParent, basename(requestedPath));
66
- if (!(await isApproved(destination, policy.roots)))
67
- return err(new EvidenceFileError("write", "outside-root"));
68
- const existing = await lstat(destination).catch((cause) => {
69
- if (isFileNotFound(cause))
70
- return undefined;
71
- throw cause;
72
- });
73
- if (existing !== undefined) {
74
- if (!overwrite)
75
- return err(new EvidenceFileError("write", "exists"));
76
- if (!existing.isFile() || existing.isSymbolicLink())
77
- return err(new EvidenceFileError("write", "not-file"));
78
- }
79
- await writeFileAtomic(destination, encoded, {
80
- encoding: "utf8",
81
- mode: 0o600,
82
- fsync: true,
83
- });
84
- return ok({ path: requestedPath, bytes });
85
- }
86
- catch (cause) {
87
- return err(new EvidenceFileError("write", "io", { cause }));
88
- }
89
- };
90
- const isApproved = async (candidate, roots) => {
91
- for (const configuredRoot of roots) {
92
- const root = await realpath(resolve(configuredRoot));
93
- const relation = relative(root, candidate);
94
- if (relation === "" ||
95
- (!relation.startsWith("..") && !isAbsolute(relation)))
96
- return true;
97
- }
98
- return false;
99
- };
100
- const validateJsonLimits = (root, policy) => {
101
- const pending = [
102
- { value: root, depth: 0 },
103
- ];
104
- let nodes = 0;
105
- while (pending.length > 0) {
106
- const current = pending.pop();
107
- if (current === undefined)
108
- break;
109
- nodes += 1;
110
- if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
111
- return err(new EvidenceFileError("read", "too-large"));
112
- if (typeof current.value === "string" &&
113
- current.value.length > policy.maxStringLength)
114
- return err(new EvidenceFileError("read", "too-large"));
115
- if (typeof current.value !== "object" || current.value === null)
116
- continue;
117
- for (const [key, value] of Object.entries(current.value)) {
118
- if (key.length > policy.maxStringLength)
119
- return err(new EvidenceFileError("read", "too-large"));
120
- pending.push({ value, depth: current.depth + 1 });
121
- }
122
- }
123
- return ok(null);
42
+ return writeBoundedText(encoded, path, overwrite, policy);
124
43
  };
125
- const isFileNotFound = (cause) => typeof cause === "object" &&
126
- cause !== null &&
127
- "code" in cause &&
128
- cause.code === "ENOENT";
@@ -1,3 +1,4 @@
1
+ import canonicalize from "canonicalize";
1
2
  import { createEvidenceBundle, parseEvidenceBundle, } from "../domain/evidenceBundle.js";
2
3
  import { EvidenceIntegrityError, EvidenceLimitError, UnknownRegistryError, } from "../domain/errors.js";
3
4
  import { parseEvidence } from "../domain/evidence.js";
@@ -258,8 +259,8 @@ export class EvidenceLedger {
258
259
  }
259
260
  const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
260
261
  const unknownRevisionKey = (unknown) => `${unknown.unknown_id}:${String(unknown.revision)}`;
261
- const recordsAgree = (left, right) => JSON.stringify(withoutLocalPath(left)) ===
262
- JSON.stringify(withoutLocalPath(right));
262
+ const recordsAgree = (left, right) => canonicalize(withoutLocalPath(left)) ===
263
+ canonicalize(withoutLocalPath(right));
263
264
  const withoutLocalPath = (evidence) => ({
264
265
  ...evidence,
265
266
  subject: evidence.subject === null
@@ -0,0 +1,48 @@
1
+ /** Provider identity for deterministic artifact inventories. */
2
+ export const ARTIFACT_GRAPH_PROVIDER = {
3
+ id: "rea-artifact-graph",
4
+ name: "REA safe artifact graph provider",
5
+ version: "1",
6
+ };
7
+ /** Provider identity for deterministic cross-artifact comparisons. */
8
+ export const ARTIFACT_COMPARISON_PROVIDER = {
9
+ id: "rea-artifact-comparison",
10
+ name: "REA artifact comparison",
11
+ version: "1",
12
+ };
13
+ /** Provider identity for deterministic cross-function comparisons. */
14
+ export const FUNCTION_COMPARISON_PROVIDER = {
15
+ id: "rea-function-comparison",
16
+ name: "REA function comparison",
17
+ version: "1",
18
+ };
19
+ /** Provider identity for deterministic Evidence bundle comparisons. */
20
+ export const BUNDLE_COMPARISON_PROVIDER = {
21
+ id: "rea-bundle-comparison",
22
+ name: "REA Evidence bundle comparison",
23
+ version: "1",
24
+ };
25
+ /** Provider identity for comparison-Evidence behavior aggregation. */
26
+ export const CHANGED_BEHAVIOR_PROVIDER = {
27
+ id: "rea-changed-behavior",
28
+ name: "REA changed behavior investigation",
29
+ version: "1",
30
+ };
31
+ /** Provider identity for Evidence-backed call-path reconstruction. */
32
+ export const CALL_PATH_PROVIDER = {
33
+ id: "rea-call-path",
34
+ name: "REA call path reconstruction",
35
+ version: "1",
36
+ };
37
+ /** Provider identity for explicit static/runtime hypothesis correlation. */
38
+ export const STATIC_RUNTIME_PROVIDER = {
39
+ id: "rea-static-runtime-correlation",
40
+ name: "REA static/runtime correlation",
41
+ version: "1",
42
+ };
43
+ /** Provider identity for finite reconstruction specification verification. */
44
+ export const RECONSTRUCTION_PROVIDER = {
45
+ id: "rea-reconstruction-verifier",
46
+ name: "REA reconstruction verifier",
47
+ version: "1",
48
+ };
@@ -0,0 +1,214 @@
1
+ import { lstat, open, readFile, realpath, unlink, } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { InvestigationWorkspaceError } from "../domain/errors.js";
5
+ import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
6
+ import { err, ok } from "../domain/result.js";
7
+ /** Read a validated workspace, returning null only when it does not exist. */
8
+ export const readInvestigationWorkspace = async (path, policy) => {
9
+ if (policy.roots.length === 0)
10
+ return err(new InvestigationWorkspaceError("read", "disabled"));
11
+ try {
12
+ const destination = await resolveDestination(path, policy.roots);
13
+ if (destination === null)
14
+ return err(new InvestigationWorkspaceError("read", "outside-root"));
15
+ return await readWorkspaceFile(destination, policy);
16
+ }
17
+ catch (cause) {
18
+ return err(new InvestigationWorkspaceError("read", "io", { cause }));
19
+ }
20
+ };
21
+ /** Atomically append one CAS-linked workspace revision under an exclusive lock. */
22
+ export const writeInvestigationWorkspace = async (workspace, path, expectedRevision, policy) => {
23
+ if (policy.roots.length === 0)
24
+ return err(new InvestigationWorkspaceError("update", "disabled"));
25
+ let encoded;
26
+ try {
27
+ encoded = serializeInvestigationWorkspace(workspace);
28
+ }
29
+ catch (cause) {
30
+ return err(new InvestigationWorkspaceError("update", "integrity", { cause }));
31
+ }
32
+ const bytes = Buffer.byteLength(encoded, "utf8");
33
+ if (bytes > policy.maxBytes)
34
+ return err(new InvestigationWorkspaceError("update", "too-large"));
35
+ let lock;
36
+ try {
37
+ const destination = await resolveDestination(path, policy.roots);
38
+ if (destination === null)
39
+ return err(new InvestigationWorkspaceError("update", "outside-root"));
40
+ const acquired = await acquireLock(destination);
41
+ if (!acquired.ok)
42
+ return acquired;
43
+ lock = acquired.value;
44
+ const current = await readWorkspaceFile(destination, policy);
45
+ if (!current.ok)
46
+ return current;
47
+ const checked = validateNextRevision(current.value, workspace, expectedRevision);
48
+ if (!checked.ok)
49
+ return checked;
50
+ await writeFileAtomic(destination, encoded, {
51
+ encoding: "utf8",
52
+ mode: 0o600,
53
+ fsync: true,
54
+ });
55
+ return ok({ path: resolve(path), bytes });
56
+ }
57
+ catch (cause) {
58
+ return err(new InvestigationWorkspaceError("update", "io", { cause }));
59
+ }
60
+ finally {
61
+ if (lock !== undefined)
62
+ await releaseLock(lock);
63
+ }
64
+ };
65
+ const resolveDestination = async (path, roots) => {
66
+ const requested = resolve(path);
67
+ const canonicalParent = await realpath(dirname(requested));
68
+ const destination = resolve(canonicalParent, basename(requested));
69
+ for (const configuredRoot of roots) {
70
+ const root = await realpath(resolve(configuredRoot));
71
+ const relation = relative(root, destination);
72
+ if (relation === "" ||
73
+ (!relation.startsWith("..") && !isAbsolute(relation)))
74
+ return destination;
75
+ }
76
+ return null;
77
+ };
78
+ const readWorkspaceFile = async (destination, policy) => {
79
+ const stats = await lstat(destination).catch((cause) => {
80
+ if (isFileNotFound(cause))
81
+ return undefined;
82
+ throw cause;
83
+ });
84
+ if (stats === undefined)
85
+ return ok(null);
86
+ if (!stats.isFile() || stats.isSymbolicLink())
87
+ return err(new InvestigationWorkspaceError("read", "not-file"));
88
+ if (stats.size > policy.maxBytes)
89
+ return err(new InvestigationWorkspaceError("read", "too-large"));
90
+ const encoded = await readFile(destination);
91
+ if (encoded.byteLength > policy.maxBytes)
92
+ return err(new InvestigationWorkspaceError("read", "too-large"));
93
+ let decoded;
94
+ try {
95
+ decoded = JSON.parse(encoded.toString("utf8"));
96
+ }
97
+ catch (cause) {
98
+ return err(new InvestigationWorkspaceError("read", "invalid-json", { cause }));
99
+ }
100
+ if (!withinJsonLimits(decoded, policy))
101
+ return err(new InvestigationWorkspaceError("read", "too-large"));
102
+ try {
103
+ return ok(parseInvestigationWorkspace(decoded));
104
+ }
105
+ catch (cause) {
106
+ return err(new InvestigationWorkspaceError("read", "integrity", { cause }));
107
+ }
108
+ };
109
+ const acquireLock = async (destination) => {
110
+ const path = `${destination}.lock`;
111
+ try {
112
+ return ok(await createLock(path));
113
+ }
114
+ catch (cause) {
115
+ if (isAlreadyExists(cause) && (await removeStaleLock(path))) {
116
+ try {
117
+ return ok(await createLock(path));
118
+ }
119
+ catch (retryCause) {
120
+ return err(new InvestigationWorkspaceError("update", isAlreadyExists(retryCause) ? "locked" : "io", { cause: retryCause }));
121
+ }
122
+ }
123
+ return err(new InvestigationWorkspaceError("update", isAlreadyExists(cause) ? "locked" : "io", { cause }));
124
+ }
125
+ };
126
+ const createLock = async (path) => {
127
+ const handle = await open(path, "wx", 0o600);
128
+ try {
129
+ await handle.writeFile(`${String(process.pid)}\n`, "utf8");
130
+ await handle.sync();
131
+ return { path, handle };
132
+ }
133
+ catch (cause) {
134
+ await handle.close().catch(() => undefined);
135
+ await unlink(path).catch(() => undefined);
136
+ throw cause;
137
+ }
138
+ };
139
+ const removeStaleLock = async (path) => {
140
+ try {
141
+ const stats = await lstat(path);
142
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.size > 32)
143
+ return false;
144
+ const encoded = (await readFile(path, "utf8")).trim();
145
+ if (!/^[1-9][0-9]{0,9}$/u.test(encoded))
146
+ return false;
147
+ const pid = Number(encoded);
148
+ if (processIsAlive(pid))
149
+ return false;
150
+ await unlink(path);
151
+ return true;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ };
157
+ const processIsAlive = (pid) => {
158
+ try {
159
+ process.kill(pid, 0);
160
+ return true;
161
+ }
162
+ catch (cause) {
163
+ return errorCode(cause) !== "ESRCH";
164
+ }
165
+ };
166
+ const releaseLock = async (lock) => {
167
+ await lock.handle.close().catch(() => undefined);
168
+ await unlink(lock.path).catch(() => undefined);
169
+ };
170
+ const validateNextRevision = (current, next, expectedRevision) => {
171
+ if ((current?.revision ?? null) !== expectedRevision)
172
+ return err(new InvestigationWorkspaceError("update", "revision-conflict"));
173
+ if (current === null) {
174
+ return next.revision === 1 && next.previous_revision_digest === null
175
+ ? ok(null)
176
+ : err(new InvestigationWorkspaceError("update", "revision-conflict"));
177
+ }
178
+ if (current.name !== next.name || current.workspace_id !== next.workspace_id)
179
+ return err(new InvestigationWorkspaceError("update", "name-conflict"));
180
+ return next.revision === current.revision + 1 &&
181
+ next.previous_revision_digest === current.revision_digest
182
+ ? ok(null)
183
+ : err(new InvestigationWorkspaceError("update", "revision-conflict"));
184
+ };
185
+ const withinJsonLimits = (root, policy) => {
186
+ const pending = [
187
+ { value: root, depth: 0 },
188
+ ];
189
+ let nodes = 0;
190
+ while (pending.length > 0) {
191
+ const current = pending.pop();
192
+ if (current === undefined)
193
+ break;
194
+ nodes += 1;
195
+ if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
196
+ return false;
197
+ if (typeof current.value === "string" &&
198
+ current.value.length > policy.maxStringLength)
199
+ return false;
200
+ if (typeof current.value !== "object" || current.value === null)
201
+ continue;
202
+ for (const [key, value] of Object.entries(current.value)) {
203
+ if (key.length > policy.maxStringLength)
204
+ return false;
205
+ pending.push({ value, depth: current.depth + 1 });
206
+ }
207
+ }
208
+ return true;
209
+ };
210
+ const isFileNotFound = (cause) => errorCode(cause) === "ENOENT";
211
+ const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
212
+ const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
213
+ ? cause.code
214
+ : undefined;