mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
import { parseConfig, type AppConfig } from "../config.js";
|
|
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
|
import { createServerIdentity } from "../serverIdentity.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { EnhancedTools } from "./EnhancedTools.js";
|
|
import { createBinarySession } from "./runtime.js";
|
|
import { silentLogger, type Logger } from "../logger.js";
|
|
import { createEvidence } from "../domain/evidence.js";
|
|
import type { Evidence } from "../domain/evidence.js";
|
|
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
|
|
import type { ArtifactToolName } from "../contracts/artifactToolContracts.js";
|
|
import {
|
|
EvidenceIntegrityError,
|
|
AnalysisProtocolError,
|
|
PermissionRequiredError,
|
|
projectAnalysisError,
|
|
type AnalysisError,
|
|
} from "../domain/errors.js";
|
|
import { access } from "node:fs/promises";
|
|
import {
|
|
readAnalysisSnapshot,
|
|
writeAnalysisSnapshot,
|
|
} from "./AnalysisSnapshotFiles.js";
|
|
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
|
import {
|
|
snapshotEvidenceForQuery,
|
|
snapshotMatchesTarget,
|
|
} from "../domain/analysisSnapshot.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
|
import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
|
|
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
|
import {
|
|
authorizeFileReadWithDeferredWrite,
|
|
type DeferredFileWriteAuthorization,
|
|
} from "./DeferredFileAuthorization.js";
|
|
|
|
const WORKFLOW_PROVIDER = {
|
|
id: "rea-workflow",
|
|
name: "REA composed investigation workflow",
|
|
version: "1",
|
|
} as const;
|
|
|
|
type DirectAnalysisTool =
|
|
| "binary_overview"
|
|
| "procedure_pseudo_code"
|
|
| "analyze_function"
|
|
| "search_strings"
|
|
| "search_procedures"
|
|
| "xrefs"
|
|
| "trace_feature";
|
|
|
|
/**
|
|
* Open one binary, execute one tool, and always release the bridge session.
|
|
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
|
|
* retain a target or bridge resources for a subsequent command.
|
|
*/
|
|
export const runDirectAnalysis = async (
|
|
path: string,
|
|
tool: DirectAnalysisTool,
|
|
arguments_: Readonly<Record<string, JsonValue>>,
|
|
options: {
|
|
readonly logger?: Logger;
|
|
readonly snapshotPath?: string | undefined;
|
|
readonly signal?: AbortSignal;
|
|
readonly permissionAuthority?: PermissionAuthority;
|
|
} = {},
|
|
): Promise<JsonValue> =>
|
|
withProcessCancellation(options.signal, (signal) =>
|
|
runAnalysis(path, tool, arguments_, {
|
|
logger: options.logger ?? silentLogger,
|
|
snapshotPath: options.snapshotPath,
|
|
signal,
|
|
...(options.permissionAuthority === undefined
|
|
? {}
|
|
: { permissionAuthority: options.permissionAuthority }),
|
|
}),
|
|
);
|
|
|
|
/** Execute one provider-native semantic operation with atomic provenance. */
|
|
export const runProviderAnalysis = async (
|
|
path: string,
|
|
tool: NativeToolName | ArtifactToolName,
|
|
arguments_: Readonly<Record<string, JsonValue>>,
|
|
logger: Logger = silentLogger,
|
|
signal?: AbortSignal,
|
|
): Promise<JsonValue> =>
|
|
withProcessCancellation(signal, (operationSignal) =>
|
|
runAnalysis(path, tool, arguments_, {
|
|
logger,
|
|
snapshotPath: undefined,
|
|
signal: operationSignal,
|
|
}),
|
|
);
|
|
|
|
/** Describe configured providers without opening a target or launching Hopper. */
|
|
export const runSessionStatus = async (
|
|
logger: Logger = silentLogger,
|
|
): Promise<JsonValue> => {
|
|
const config = parseConfig(process.env);
|
|
if (!config.ok) return cliError(config.error);
|
|
const session = createBinarySession(config.value, logger);
|
|
try {
|
|
return {
|
|
...jsonObjectSchema.parse(session.status()),
|
|
server_identity: createServerIdentity({
|
|
startedAt: new Date().toISOString(),
|
|
}),
|
|
};
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
};
|
|
|
|
const authorizeAnalysis = async (
|
|
authority: PermissionAuthority,
|
|
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
|
arguments_: Readonly<Record<string, JsonValue>>,
|
|
): Promise<Result<null, AnalysisError>> => {
|
|
const requests = [];
|
|
if (tool === "extract_artifact" && typeof arguments_.output_root === "string")
|
|
requests.push({
|
|
capability: "artifact_extract" as const,
|
|
path: arguments_.output_root,
|
|
access: "write" as const,
|
|
});
|
|
for (const request of requests) {
|
|
const result = await authority.authorize(
|
|
{
|
|
capability: request.capability,
|
|
roots: [request.path],
|
|
executables: [],
|
|
environment_names: [],
|
|
network: "none",
|
|
mount: false,
|
|
operation_identity: `${tool}:${request.capability}:${request.path}`,
|
|
},
|
|
request.access,
|
|
);
|
|
if (!result.ok)
|
|
return err(
|
|
result.error instanceof PermissionRequiredError
|
|
? result.error
|
|
: new AnalysisProtocolError(result.error.message, {
|
|
cause: result.error,
|
|
}),
|
|
);
|
|
}
|
|
if (
|
|
tool === "inventory_artifact" &&
|
|
arguments_.native_mount_approved === true
|
|
) {
|
|
const result = await authority.authorize(
|
|
{
|
|
capability: "native_mount",
|
|
roots: [],
|
|
executables: [],
|
|
environment_names: [],
|
|
network: "none",
|
|
mount: true,
|
|
operation_identity: "inventory_artifact:native_mount",
|
|
},
|
|
"read",
|
|
);
|
|
if (!result.ok)
|
|
return err(
|
|
result.error instanceof PermissionRequiredError
|
|
? result.error
|
|
: new AnalysisProtocolError(result.error.message, {
|
|
cause: result.error,
|
|
}),
|
|
);
|
|
}
|
|
return ok(null);
|
|
};
|
|
|
|
const permissionAuthorityFor = (
|
|
config: AppConfig,
|
|
supplied: PermissionAuthority | undefined,
|
|
): ReturnType<typeof loadConfiguredPermissionAuthority> =>
|
|
supplied === undefined
|
|
? loadConfiguredPermissionAuthority(config)
|
|
: Promise.resolve(ok(supplied));
|
|
|
|
const authorizeSnapshotAccess = async (
|
|
authority: PermissionAuthority,
|
|
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
|
snapshotPath: string | undefined,
|
|
): Promise<
|
|
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
|
|
> =>
|
|
snapshotPath === undefined
|
|
? ok(undefined)
|
|
: authorizeFileReadWithDeferredWrite(authority, {
|
|
path: snapshotPath,
|
|
readCapability: "snapshot_read",
|
|
writeCapability: "snapshot_write",
|
|
operation: tool,
|
|
});
|
|
|
|
const authorizeDeferredWrite = (
|
|
authorization: DeferredFileWriteAuthorization | undefined,
|
|
): Promise<Result<null, AnalysisError>> =>
|
|
authorization?.authorizeWrite() ?? Promise.resolve(ok(null));
|
|
|
|
const authorizeAnalysisRun = async (input: {
|
|
readonly config: AppConfig;
|
|
readonly suppliedAuthority: PermissionAuthority | undefined;
|
|
readonly tool: NativeToolName | ArtifactToolName | DirectAnalysisTool;
|
|
readonly arguments: Readonly<Record<string, JsonValue>>;
|
|
readonly snapshotPath: string | undefined;
|
|
}): Promise<
|
|
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
|
|
> => {
|
|
const authority = await permissionAuthorityFor(
|
|
input.config,
|
|
input.suppliedAuthority,
|
|
);
|
|
if (!authority.ok) return authority;
|
|
const operation = await authorizeAnalysis(
|
|
authority.value,
|
|
input.tool,
|
|
input.arguments,
|
|
);
|
|
if (!operation.ok) return operation;
|
|
return authorizeSnapshotAccess(
|
|
authority.value,
|
|
input.tool,
|
|
input.snapshotPath,
|
|
);
|
|
};
|
|
|
|
const runAnalysis = async (
|
|
path: string,
|
|
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
|
arguments_: Readonly<Record<string, JsonValue>>,
|
|
options: {
|
|
readonly logger: Logger;
|
|
readonly snapshotPath: string | undefined;
|
|
readonly signal: AbortSignal;
|
|
readonly permissionAuthority?: PermissionAuthority;
|
|
},
|
|
): Promise<JsonValue> => {
|
|
const { logger, signal, snapshotPath } = options;
|
|
const config = parseConfig(process.env);
|
|
if (!config.ok) return cliError(config.error);
|
|
const authorization = await authorizeAnalysisRun({
|
|
config: config.value,
|
|
suppliedAuthority: options.permissionAuthority,
|
|
tool,
|
|
arguments: arguments_,
|
|
snapshotPath,
|
|
});
|
|
if (!authorization.ok) return cliError(authorization.error);
|
|
const unavailable = snapshotUnavailable(
|
|
snapshotPath,
|
|
config.value.hopperLoaderArgs,
|
|
);
|
|
if (unavailable !== undefined) return cliError(unavailable);
|
|
const session = createBinarySession(config.value, logger);
|
|
try {
|
|
const prepared = await prepareSnapshot({
|
|
path,
|
|
tool,
|
|
arguments: arguments_,
|
|
snapshotPath,
|
|
policy: config.value.analysisSnapshotFilePolicy,
|
|
provider: replayProviderFor(session, tool),
|
|
});
|
|
if (!prepared.ok) return cliError(prepared.error);
|
|
if (prepared.value.evidence !== undefined) return prepared.value.evidence;
|
|
const writable = await authorizeDeferredWrite(authorization.value);
|
|
if (!writable.ok) return cliError(writable.error);
|
|
const { snapshot } = prepared.value;
|
|
const opened = await session.open(
|
|
path,
|
|
snapshot === undefined ? { signal } : { signal, snapshot },
|
|
);
|
|
if (!opened.ok) return cliError(opened.error);
|
|
let output: JsonValue;
|
|
let evidence: Evidence | undefined;
|
|
if (
|
|
tool === "binary_overview" ||
|
|
tool === "analyze_function" ||
|
|
tool === "trace_feature"
|
|
) {
|
|
const result = await new EnhancedTools(session).execute(
|
|
tool,
|
|
arguments_,
|
|
signal,
|
|
);
|
|
if (!result.ok) output = cliError(result.error);
|
|
else {
|
|
evidence = createEvidence(
|
|
opened.value,
|
|
tool === "analyze_function"
|
|
? session.providerIdentity(tool)
|
|
: WORKFLOW_PROVIDER,
|
|
{
|
|
operation: tool,
|
|
parameters: arguments_,
|
|
result: result.value,
|
|
confidence: "derived",
|
|
limitations: ["Derived by an REA composed workflow."],
|
|
},
|
|
);
|
|
output = evidence;
|
|
}
|
|
} else {
|
|
const result = await session.execute(tool, arguments_, { signal });
|
|
if (!result.ok) output = cliError(result.error);
|
|
else {
|
|
evidence = createEvidence(
|
|
result.value.subject ?? opened.value,
|
|
result.value.provider,
|
|
{
|
|
operation: tool,
|
|
parameters: arguments_,
|
|
result: result.value.result,
|
|
rawResult: result.value.rawResult,
|
|
limitations: result.value.limitations,
|
|
locations: result.value.locations,
|
|
},
|
|
);
|
|
output = evidence;
|
|
}
|
|
}
|
|
if (evidence !== undefined) session.recordEvidence(evidence);
|
|
if (snapshotPath !== undefined && evidence !== undefined) {
|
|
const snapshot = session.exportAnalysisSnapshot();
|
|
if (!snapshot.ok) return cliError(snapshot.error);
|
|
const written = await writeAnalysisSnapshot(
|
|
snapshot.value,
|
|
snapshotPath,
|
|
true,
|
|
config.value.analysisSnapshotFilePolicy,
|
|
);
|
|
if (!written.ok) return cliError(written.error);
|
|
}
|
|
return output;
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
};
|
|
|
|
const withProcessCancellation = async <Value>(
|
|
suppliedSignal: AbortSignal | undefined,
|
|
operation: (signal: AbortSignal) => Promise<Value>,
|
|
): Promise<Value> => {
|
|
if (suppliedSignal !== undefined) return operation(suppliedSignal);
|
|
const controller = new AbortController();
|
|
const cancel = (): void => controller.abort();
|
|
process.once("SIGINT", cancel);
|
|
try {
|
|
return await operation(controller.signal);
|
|
} finally {
|
|
process.off("SIGINT", cancel);
|
|
}
|
|
};
|
|
|
|
const prepareSnapshot = async (options: {
|
|
readonly path: string;
|
|
readonly tool: string;
|
|
readonly arguments: Readonly<Record<string, JsonValue>>;
|
|
readonly snapshotPath: string | undefined;
|
|
readonly policy: Parameters<typeof readAnalysisSnapshot>[1];
|
|
readonly provider: Parameters<typeof snapshotEvidenceForQuery>[1]["provider"];
|
|
}): Promise<
|
|
Result<
|
|
{ readonly snapshot?: AnalysisSnapshot; readonly evidence?: Evidence },
|
|
AnalysisError
|
|
>
|
|
> => {
|
|
const {
|
|
path,
|
|
tool,
|
|
arguments: arguments_,
|
|
snapshotPath,
|
|
policy,
|
|
provider,
|
|
} = options;
|
|
if (snapshotPath === undefined || !(await fileExists(snapshotPath)))
|
|
return ok({});
|
|
const loaded = await readAnalysisSnapshot(snapshotPath, policy);
|
|
if (!loaded.ok) return loaded;
|
|
const target = await parseBinaryTarget(path);
|
|
if (!target.ok) return target;
|
|
if (!snapshotMatchesTarget(loaded.value.target, target.value))
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Analysis snapshot target does not match the requested binary",
|
|
),
|
|
);
|
|
const evidence = snapshotEvidenceForQuery(loaded.value, {
|
|
target: target.value,
|
|
operation: tool,
|
|
parameters: arguments_,
|
|
provider,
|
|
});
|
|
return ok(
|
|
evidence === undefined
|
|
? { snapshot: loaded.value }
|
|
: { snapshot: loaded.value, evidence },
|
|
);
|
|
};
|
|
|
|
const snapshotUnavailable = (
|
|
snapshotPath: string | undefined,
|
|
loaderArgs: readonly string[],
|
|
): EvidenceIntegrityError | undefined =>
|
|
snapshotPath !== undefined && loaderArgs.length > 0
|
|
? new EvidenceIntegrityError(
|
|
"Analysis snapshots are unavailable with custom Hopper loader arguments",
|
|
)
|
|
: undefined;
|
|
|
|
const replayProviderFor = (
|
|
session: ReturnType<typeof createBinarySession>,
|
|
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
|
) =>
|
|
tool === "binary_overview" || tool === "trace_feature"
|
|
? WORKFLOW_PROVIDER
|
|
: session.providerIdentity(tool);
|
|
|
|
const fileExists = async (path: string): Promise<boolean> => {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
if (
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
cause.code === "ENOENT"
|
|
)
|
|
return false;
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const cliError = (error: AnalysisError): JsonValue => ({
|
|
error: "Analysis failed",
|
|
...projectAnalysisError(error),
|
|
});
|