fix(native): bind dispatch snapshots to the local reader profile
Retain native-only dispatch workflows without requiring a deep provider profile. Validate exact target, operation, and current reader bindings during open, import, recording, and export; preserve selected deep bindings and caller limits. Validated public CLI and stdio MCP save/reopen, exact CLI replay, changed-limit cache partitioning, baseline failure and candidate success on an Apple-clang fixture, snapshot lifecycle regressions, typecheck and lint. No target or deep engine execution. Fixes #5529.
This commit is contained in:
1 parent
080c0e2b2b
commit
0ba4d6f43a
16 files changed
+613
-23
No files matched your search
@@ -279,6 +279,8 @@ export type CapabilityDescriptor = CapabilityAvailability & {
|
||||
* exclusions apply to every policy.
|
||||
*/
|
||||
readonly cachePolicy?: "snapshot" | "live";
|
||||
/** Exact source profile for an operation implemented by a versioned local reader. */
|
||||
readonly analysisProfile?: AnalysisProfileCommitment;
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly operation: Exclude<AnalysisOperation, "health">;
|
||||
readonly effects: CapabilityEffects;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
snapshotMatchesTarget,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import { profilelessNativeDispatchProfile } from "./binary/ProfilelessNativeWorkflowSnapshot.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
@@ -285,7 +286,8 @@ const runAnalysis = async (
|
||||
if (!preview.ok) return cliError(preview.error);
|
||||
resolvedTarget = preview.value;
|
||||
const route = preview.value.route;
|
||||
const bindingProfile = route.profile ?? undefined;
|
||||
const bindingProfile =
|
||||
route.profile ?? nativeDispatchBindingProfile(route, tool);
|
||||
const evidenceProfile = analysisProfileForRoute(route, tool);
|
||||
// A parsed Windows path does not prove Ghidra's native snapshot owner
|
||||
// admitted the selected source; CLI replay must wait for that client.
|
||||
@@ -365,6 +367,7 @@ const runAnalysis = async (
|
||||
const workflowRecord = workflowSnapshotRecord(
|
||||
evidence,
|
||||
tool,
|
||||
evidenceProfile,
|
||||
cachePolicy,
|
||||
);
|
||||
if (workflowRecord !== undefined) {
|
||||
@@ -600,13 +603,30 @@ const analysisProfileForRoute = (
|
||||
| DirectAnalysisTool,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
const profile = route.profile;
|
||||
if (profile === null || profile === undefined) return undefined;
|
||||
const nativeDispatchProfile =
|
||||
profile === null || profile === undefined
|
||||
? nativeDispatchBindingProfile(route, tool)
|
||||
: undefined;
|
||||
if (profile === null || profile === undefined) {
|
||||
if (nativeDispatchProfile === undefined) return undefined;
|
||||
return isWorkflowEvidenceTool(tool)
|
||||
? workflowAnalysisProfile(nativeDispatchProfile, tool)
|
||||
: nativeDispatchProfile;
|
||||
}
|
||||
if (isWorkflowEvidenceTool(tool))
|
||||
return workflowAnalysisProfile(profile, tool);
|
||||
const provider = providerIdentityForRoute(route, tool);
|
||||
return provider.id === profile.provider.id ? profile : undefined;
|
||||
};
|
||||
|
||||
const nativeDispatchBindingProfile = (
|
||||
route: SessionProviderRoute,
|
||||
operation: string,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
if (operation !== "inspect_native_dispatch_metadata") return undefined;
|
||||
return profilelessNativeDispatchProfile(route);
|
||||
};
|
||||
|
||||
/** Mirror BinarySession.providerIdentity for a route not opened yet. */
|
||||
const providerIdentityForRoute = (
|
||||
route: SessionProviderRoute,
|
||||
@@ -623,7 +643,7 @@ const analysisProfileForEvidence = (
|
||||
| DirectAnalysisTool,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
if (!isWorkflowEvidenceTool(tool)) return session.analysisProfile(tool);
|
||||
return session.analysisProfile();
|
||||
return session.analysisProfile() ?? session.analysisProfile(tool);
|
||||
};
|
||||
|
||||
const isWorkflowEvidenceTool = (
|
||||
|
||||
@@ -59,6 +59,7 @@ it.each([false, true])(
|
||||
workflowSnapshotRecord(
|
||||
evidence,
|
||||
"inspect_native_api",
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
composed.value.cachePolicy,
|
||||
) === undefined,
|
||||
).toBe(transient);
|
||||
|
||||
@@ -133,12 +133,18 @@ const batchProcedureLocations = (result: JsonValue): EvidenceLocation[] => {
|
||||
export const workflowSnapshotRecord = (
|
||||
evidence: Evidence,
|
||||
operation: AnalysisOperation,
|
||||
bindingProfile: AnalysisProfileCommitment | undefined,
|
||||
cachePolicy?: "live",
|
||||
): WorkflowSnapshotRecordInput | undefined => {
|
||||
if (evidence.analysis_profile === null || cachePolicy === "live")
|
||||
if (
|
||||
evidence.analysis_profile === null ||
|
||||
bindingProfile === undefined ||
|
||||
cachePolicy === "live"
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
operation,
|
||||
bindingProfile,
|
||||
parameters: evidence.parameters,
|
||||
execution: {
|
||||
result: evidence.normalized_result,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
@@ -64,6 +65,7 @@ interface SessionRun {
|
||||
interface SessionBinding extends SessionRun {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly profilelessWorkflowProfile?: AnalysisProfileCommitment;
|
||||
nativeSourceAdmitted: boolean;
|
||||
}
|
||||
|
||||
@@ -173,7 +175,12 @@ export class BinarySession
|
||||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment | undefined {
|
||||
const profile = this.#active?.profile;
|
||||
if (profile === null || profile === undefined) return undefined;
|
||||
if (profile === null || profile === undefined) {
|
||||
if (operation !== "inspect_native_dispatch_metadata") return undefined;
|
||||
return this.#active?.profilelessWorkflowProfile === undefined
|
||||
? undefined
|
||||
: structuredClone(this.#active.profilelessWorkflowProfile);
|
||||
}
|
||||
if (
|
||||
operation !== undefined &&
|
||||
this.providerIdentity(operation).id !== profile.provider.id
|
||||
@@ -358,9 +365,14 @@ export class BinarySession
|
||||
),
|
||||
);
|
||||
}
|
||||
const profilelessWorkflowProfile =
|
||||
profile === null ? profilelessNativeDispatchProfile(route) : undefined;
|
||||
this.#active = {
|
||||
target,
|
||||
profile,
|
||||
...(profilelessWorkflowProfile === undefined
|
||||
? {}
|
||||
: { profilelessWorkflowProfile }),
|
||||
...run,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
import {
|
||||
isProfilelessNativeDispatchSnapshot,
|
||||
profilelessNativeDispatchProfile,
|
||||
} from "./ProfilelessNativeWorkflowSnapshot.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
|
||||
/** Read a staged snapshot once the open holds its place in the session order. */
|
||||
@@ -126,6 +130,7 @@ export const validateResolvedSessionTarget = (
|
||||
snapshot: options.snapshot,
|
||||
target,
|
||||
profile: route.profile,
|
||||
profilelessWorkflowProfile: profilelessNativeDispatchProfile(route),
|
||||
current,
|
||||
stagedSnapshotMatches,
|
||||
});
|
||||
@@ -147,6 +152,7 @@ interface ValidateSnapshotInput {
|
||||
readonly snapshot: AnalysisSnapshot | undefined;
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly profilelessWorkflowProfile: AnalysisProfileCommitment | undefined;
|
||||
readonly current: CurrentOpenBinding | undefined;
|
||||
readonly stagedSnapshotMatches: (
|
||||
target: BinaryTarget,
|
||||
@@ -158,17 +164,31 @@ const validateSnapshot = ({
|
||||
snapshot,
|
||||
target,
|
||||
profile,
|
||||
profilelessWorkflowProfile,
|
||||
current,
|
||||
stagedSnapshotMatches,
|
||||
}: ValidateSnapshotInput): EvidenceIntegrityError | undefined => {
|
||||
if (
|
||||
snapshot !== undefined &&
|
||||
(profile === null || !snapshotMatchesBinding(snapshot, target, profile))
|
||||
(profile === null
|
||||
? profilelessWorkflowProfile === undefined ||
|
||||
!isProfilelessNativeDispatchSnapshot(
|
||||
snapshot,
|
||||
target,
|
||||
profilelessWorkflowProfile,
|
||||
)
|
||||
: !snapshotMatchesBinding(snapshot, target, profile))
|
||||
)
|
||||
return new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary",
|
||||
);
|
||||
if (current === undefined && !stagedSnapshotMatches(target, profile))
|
||||
if (
|
||||
current === undefined &&
|
||||
!stagedSnapshotMatches(
|
||||
target,
|
||||
profile ?? profilelessWorkflowProfile ?? null,
|
||||
)
|
||||
)
|
||||
return new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import { analysisProfilesEqual } from "../../domain/analysisProfile.js";
|
||||
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
@@ -33,6 +34,11 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { writeAnalysisSnapshot as writeSnapshotFile } from "./AnalysisSnapshotFiles.js";
|
||||
import { workflowAnalysisProfile } from "../InvestigationProviders.js";
|
||||
import {
|
||||
hasOnlyProfilelessNativeDispatchEntries,
|
||||
isProfilelessNativeDispatchSnapshot,
|
||||
} from "./ProfilelessNativeWorkflowSnapshot.js";
|
||||
|
||||
const SNAPSHOT_MUTATION_RECOVERY =
|
||||
"Export session observations through export_evidence_bundle if needed. Close the active target without saving a snapshot, then reopen it before importing or saving a snapshot.";
|
||||
@@ -40,11 +46,14 @@ const SNAPSHOT_MUTATION_RECOVERY =
|
||||
export interface ActiveAnalysisBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
/** Current route profile for the single profileless native dispatch workflow. */
|
||||
readonly profilelessWorkflowProfile?: AnalysisProfileCommitment;
|
||||
}
|
||||
|
||||
/** Validated composed-workflow payload bound to the active target and profile. */
|
||||
export interface WorkflowSnapshotRecordInput {
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly bindingProfile: AnalysisProfileCommitment;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly execution: Parameters<
|
||||
AnalysisSnapshotCache["recordWorkflow"]
|
||||
@@ -160,13 +169,30 @@ export abstract class BinarySessionRecords {
|
||||
),
|
||||
);
|
||||
const target = active?.target;
|
||||
const profile = active?.profile ?? undefined;
|
||||
const profile =
|
||||
active?.profile ?? active?.profilelessWorkflowProfile ?? undefined;
|
||||
if (target !== undefined && profile === undefined)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots require a concrete provider analysis profile",
|
||||
),
|
||||
);
|
||||
if (
|
||||
active?.profile === null &&
|
||||
(target === undefined ||
|
||||
profile === undefined ||
|
||||
!this.#snapshot.matches(target, profile) ||
|
||||
this.#snapshot.entries().length !== 0 ||
|
||||
!hasOnlyProfilelessNativeDispatchEntries(
|
||||
this.#snapshot.workflowEntries(),
|
||||
profile,
|
||||
))
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Profileless native snapshots may contain only current dispatch workflow observations",
|
||||
),
|
||||
);
|
||||
return this.#snapshot.export(
|
||||
target,
|
||||
profile,
|
||||
@@ -192,17 +218,24 @@ export abstract class BinarySessionRecords {
|
||||
},
|
||||
),
|
||||
);
|
||||
if (active?.profile === null)
|
||||
const profile = active?.profile ?? active?.profilelessWorkflowProfile;
|
||||
if (
|
||||
active?.profile === null &&
|
||||
(profile === undefined ||
|
||||
!isProfilelessNativeDispatchSnapshot(snapshot, active.target, profile))
|
||||
)
|
||||
return err(
|
||||
new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: the active target has no concrete analysis profile",
|
||||
"Analysis snapshot profile_mismatch: the snapshot is not bound to the active native dispatch reader",
|
||||
),
|
||||
);
|
||||
const imported = this.#snapshot.import(
|
||||
snapshot,
|
||||
active === undefined
|
||||
? undefined
|
||||
: { target: active.target, profile: active.profile },
|
||||
: profile === undefined
|
||||
? undefined
|
||||
: { target: active.target, profile },
|
||||
(bundle) => {
|
||||
const imported = this.#records.mergeEvidenceBundle(bundle);
|
||||
return imported.ok ? ok(imported.value.recordsAdded) : imported;
|
||||
@@ -252,17 +285,33 @@ export abstract class BinarySessionRecords {
|
||||
input: WorkflowSnapshotRecordInput,
|
||||
): Result<null, EvidenceIntegrityError> {
|
||||
const active = this.activeAnalysisBinding();
|
||||
if (active === undefined || active.profile === null)
|
||||
if (active === undefined)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Workflow snapshot entries require an active concrete provider profile",
|
||||
),
|
||||
);
|
||||
if (this.#snapshotInvalidated) return ok(null);
|
||||
const profile = active.profile ?? active.profilelessWorkflowProfile;
|
||||
if (
|
||||
profile === undefined ||
|
||||
!analysisProfilesEqual(profile, input.bindingProfile) ||
|
||||
!analysisProfilesEqual(
|
||||
input.execution.analysisProfile,
|
||||
workflowAnalysisProfile(profile, input.operation),
|
||||
) ||
|
||||
(active.profile === null &&
|
||||
input.operation !== "inspect_native_dispatch_metadata")
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Workflow snapshot entry does not match the active reader profile",
|
||||
),
|
||||
);
|
||||
try {
|
||||
this.#snapshot.recordWorkflow({
|
||||
target: active.target,
|
||||
profile: active.profile,
|
||||
profile,
|
||||
...input,
|
||||
});
|
||||
this.#emitSnapshotChanged();
|
||||
|
||||
@@ -16,6 +16,11 @@ import type {
|
||||
SessionProviderRouter,
|
||||
} from "./SessionProviderRouter.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
import {
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
nativeMacOSCapabilities,
|
||||
} from "../../native/NativeMacOSProviderMetadata.js";
|
||||
|
||||
const projectedStatusSchema = sessionProvider.pick({
|
||||
capabilities: true,
|
||||
@@ -326,6 +331,21 @@ const expectedExpandedCandidateCapabilities = [
|
||||
];
|
||||
|
||||
describe("binary session provider capability projection", () => {
|
||||
it("keeps the private dispatch reader profile out of public capability status", async () => {
|
||||
const capabilities = nativeMacOSCapabilities("darwin");
|
||||
const projected = await project(capabilities, [
|
||||
candidate(NATIVE_MACOS_PROVIDER_IDENTITY, capabilities),
|
||||
]);
|
||||
const serialized = JSON.stringify(projected);
|
||||
expect(serialized).not.toContain(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider.version,
|
||||
);
|
||||
expect(serialized).not.toContain(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
|
||||
);
|
||||
expect(projectedStatusSchema.parse(projected)).toEqual(projected);
|
||||
});
|
||||
|
||||
it("preserves provider scope and route overlays while factoring exact prefixes", async () => {
|
||||
const selectedBefore = structuredClone(selectedCapabilities);
|
||||
const otherBefore = structuredClone(otherCapabilities);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { analysisProfilesEqual } from "../../domain/analysisProfile.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import {
|
||||
snapshotMatchesBinding,
|
||||
type AnalysisSnapshot,
|
||||
} from "../../domain/analysisSnapshot.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import { workflowAnalysisProfile } from "../InvestigationProviders.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
|
||||
/** Return the exact currently declared native dispatch reader profile, if any. */
|
||||
export const profilelessNativeDispatchProfile = (
|
||||
route: SessionProviderRoute,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
const descriptor = route.capabilities.get("inspect_native_dispatch_metadata");
|
||||
const profile = descriptor?.analysisProfile;
|
||||
if (
|
||||
descriptor === undefined ||
|
||||
descriptor.operation !== "inspect_native_dispatch_metadata" ||
|
||||
!descriptor.available ||
|
||||
descriptor.provider.id !== "native-macos" ||
|
||||
profile === undefined ||
|
||||
profile.provider.id !== descriptor.provider.id ||
|
||||
profile.provider.name !== descriptor.provider.name ||
|
||||
profile.provider.version.length === 0
|
||||
)
|
||||
return undefined;
|
||||
return structuredClone(profile);
|
||||
};
|
||||
|
||||
/** Require only exact native dispatch workflow observations for this target/profile. */
|
||||
export const isProfilelessNativeDispatchSnapshot = (
|
||||
snapshot: AnalysisSnapshot,
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment,
|
||||
): boolean =>
|
||||
snapshotMatchesBinding(snapshot, target, profile) &&
|
||||
snapshot.entries.length === 0 &&
|
||||
hasOnlyProfilelessNativeDispatchEntries(snapshot.workflow_entries, profile);
|
||||
|
||||
/** Reject mixed operations and entries derived from a different source profile. */
|
||||
export const hasOnlyProfilelessNativeDispatchEntries = (
|
||||
entries: readonly {
|
||||
readonly operation: string;
|
||||
readonly execution: {
|
||||
readonly analysis_profile: AnalysisProfileCommitment;
|
||||
};
|
||||
}[],
|
||||
profile: AnalysisProfileCommitment,
|
||||
): boolean =>
|
||||
entries.length > 0 &&
|
||||
entries.every(
|
||||
(entry) =>
|
||||
entry.operation === "inspect_native_dispatch_metadata" &&
|
||||
analysisProfilesEqual(
|
||||
entry.execution.analysis_profile,
|
||||
workflowAnalysisProfile(profile, entry.operation),
|
||||
),
|
||||
);
|
||||
@@ -26,7 +26,10 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js";
|
||||
import {
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY,
|
||||
} from "./NativeMacOSProviderMetadata.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { pushDispatchCoverage } from "./AppleDispatchCoverage.js";
|
||||
@@ -249,12 +252,8 @@ export const inspectAppleDispatchMetadata = async (
|
||||
);
|
||||
return nativeDispatchMetadataResultSchema.parse({
|
||||
target_sha256: target.sha256,
|
||||
provider: {
|
||||
id: "native-macos",
|
||||
name: "macOS native inspection utilities",
|
||||
version: "apple-metadata-reader-1",
|
||||
},
|
||||
analysis_profile_digest: null,
|
||||
provider: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider,
|
||||
analysis_profile_digest: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
|
||||
result: decodeAppleDispatchMetadata(
|
||||
bytes,
|
||||
maxRecords,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import {
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY,
|
||||
nativeMacOSCapabilities,
|
||||
} from "./NativeMacOSProviderMetadata.js";
|
||||
@@ -237,6 +238,7 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(result, result.provider, {
|
||||
analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
locations: [{ kind: "artifact-path", path: this.target.path }],
|
||||
limitations: result.result.coverage.flatMap(({ reason }) =>
|
||||
reason === null ? [] : [reason],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { createAnalysisProfile } from "../domain/analysisProfile.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
|
||||
/** Public identity committed by macOS-native inspection observations. */
|
||||
@@ -10,6 +11,19 @@ export const NATIVE_MACOS_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
|
||||
name: "macOS native inspection utilities",
|
||||
version: null,
|
||||
});
|
||||
/** Source-owned commitment for the pure Apple dispatch metadata reader. */
|
||||
export const APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE = createAnalysisProfile(
|
||||
{
|
||||
id: NATIVE_MACOS_PROVIDER_IDENTITY.id,
|
||||
name: NATIVE_MACOS_PROVIDER_IDENTITY.name,
|
||||
version: "apple-metadata-reader-1",
|
||||
},
|
||||
{
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
reader: "apple-dispatch-metadata",
|
||||
revision: 1,
|
||||
},
|
||||
);
|
||||
/** Operations whose target process can show UI, use the network and write files. */
|
||||
const OWNED_PROCESS_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"capture_native_ui_scenario",
|
||||
@@ -36,6 +50,9 @@ export const nativeMacOSCapabilities = (
|
||||
return Object.freeze({
|
||||
provider: NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
operation: contract.name,
|
||||
...(contract.name === "inspect_native_dispatch_metadata"
|
||||
? { analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE }
|
||||
: {}),
|
||||
...availability,
|
||||
effects: Object.freeze({
|
||||
mutatesArtifact: false,
|
||||
|
||||
@@ -905,7 +905,10 @@ const registerBinaryAnalysisTools = ({
|
||||
registerEnhancedTools(server, {
|
||||
...analysisOptions,
|
||||
analysisProfile:
|
||||
session === undefined ? undefined : () => session.analysisProfile(),
|
||||
session === undefined
|
||||
? undefined
|
||||
: (operation) =>
|
||||
session.analysisProfile() ?? session.analysisProfile(operation),
|
||||
allowsSnapshotReplay:
|
||||
session === undefined
|
||||
? undefined
|
||||
|
||||
@@ -37,7 +37,9 @@ export interface EnhancedToolRegistration {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly analysisProfile:
|
||||
| (() => AnalysisProfileCommitment | undefined)
|
||||
| ((
|
||||
operation: ValidatedEnhancedCall["name"],
|
||||
) => AnalysisProfileCommitment | undefined)
|
||||
| undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined;
|
||||
@@ -260,7 +262,7 @@ const executeEnhancedTool = async (
|
||||
operation: name,
|
||||
parameters,
|
||||
result: result.value.result,
|
||||
upstreamProfile: registration.analysisProfile?.(),
|
||||
upstreamProfile: registration.analysisProfile?.(name),
|
||||
upstreamLimitations: result.value.limitations,
|
||||
});
|
||||
const recorded = registration.recordEvidence?.(evidence);
|
||||
@@ -273,6 +275,7 @@ const executeEnhancedTool = async (
|
||||
const workflowRecord = workflowSnapshotRecord(
|
||||
evidence,
|
||||
name,
|
||||
registration.analysisProfile?.(name),
|
||||
result.value.cachePolicy,
|
||||
);
|
||||
if (workflowRecord !== undefined) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { readAnalysisSnapshot } from "../../src/application/binary/AnalysisSnapshotFiles.js";
|
||||
import { APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } from "../../src/native/NativeMacOSProviderMetadata.js";
|
||||
import {
|
||||
CPU,
|
||||
machoImage,
|
||||
} from "../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.skipIf(process.platform !== "darwin")(
|
||||
"retains and reopens profileless native dispatch workflow snapshots through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-native-dispatch-snapshot-");
|
||||
const target = join(root, "minimal.macho");
|
||||
const cliSnapshot = join(root, "cli-snapshot.json");
|
||||
const mcpSnapshot = join(root, "mcp-snapshot.json");
|
||||
const bytes = machoImage({ cpu: CPU.arm64, commands: [] });
|
||||
await writeFile(target, bytes);
|
||||
const isolatedProviderEnvironment = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
||||
GHIDRA_INSTALL_DIR: "/rea-unconfigured-deep-provider/ghidra",
|
||||
JAVA_HOME: "/rea-unconfigured-deep-provider/java",
|
||||
REA_IDA_MCP_CONFIG: "/rea-unconfigured-deep-provider/ida-mcp.json",
|
||||
REA_KUNA_COMMAND: "/rea-unconfigured-deep-provider/kuna",
|
||||
REA_KUNA_CACHE_DIR: "/rea-unconfigured-deep-provider/kuna-cache",
|
||||
};
|
||||
|
||||
const cliArguments = [
|
||||
"inspect-native-dispatch-metadata",
|
||||
target,
|
||||
"--snapshot",
|
||||
cliSnapshot,
|
||||
"--json",
|
||||
];
|
||||
const firstCli = await cli.run({
|
||||
arguments: cliArguments,
|
||||
environment: isolatedProviderEnvironment,
|
||||
});
|
||||
expect(firstCli.exitCode, firstCli.stderr).toBe(0);
|
||||
const cliEvidence = toolContract(
|
||||
"inspect_native_dispatch_metadata",
|
||||
).outputSchema.parse(firstCli.json);
|
||||
const cliSaved = await readAnalysisSnapshot(cliSnapshot);
|
||||
if (!cliSaved.ok) throw cliSaved.error;
|
||||
expect(cliSaved.value.binding.analysis_profile).toEqual(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
);
|
||||
expect(cliSaved.value.entries).toEqual([]);
|
||||
expect(cliSaved.value.workflow_entries).toHaveLength(1);
|
||||
const replayCli = await cli.run({
|
||||
arguments: cliArguments,
|
||||
environment: isolatedProviderEnvironment,
|
||||
});
|
||||
expect(replayCli.exitCode, replayCli.stderr).toBe(0);
|
||||
expect(
|
||||
toolContract("inspect_native_dispatch_metadata").outputSchema.parse(
|
||||
replayCli.json,
|
||||
),
|
||||
).toEqual(cliEvidence);
|
||||
const changedLimitCli = await cli.run({
|
||||
arguments: [...cliArguments, "--max-records", "1"],
|
||||
environment: isolatedProviderEnvironment,
|
||||
});
|
||||
expect(changedLimitCli.exitCode, changedLimitCli.stderr).toBe(0);
|
||||
const changedLimitEvidence = parseEvidence(changedLimitCli.json);
|
||||
expect(changedLimitEvidence.parameters.max_records).toBe(1);
|
||||
expect(changedLimitEvidence.evidence_id).not.toBe(cliEvidence.evidence_id);
|
||||
const changedLimitSnapshot = await readAnalysisSnapshot(cliSnapshot);
|
||||
if (!changedLimitSnapshot.ok) throw changedLimitSnapshot.error;
|
||||
expect(changedLimitSnapshot.value.workflow_entries).toHaveLength(2);
|
||||
|
||||
const { client, call } = await connectLocalToolsMcp(
|
||||
isolatedProviderEnvironment,
|
||||
);
|
||||
const opened = await call("open_binary", { path: target });
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const firstMcp = await call("inspect_native_dispatch_metadata", {
|
||||
max_records: 100,
|
||||
});
|
||||
expect(firstMcp.isError, JSON.stringify(firstMcp)).not.toBe(true);
|
||||
const mcpEvidence = parseEvidence(firstMcp.structuredContent);
|
||||
const closed = await call("close_binary", {
|
||||
snapshot_path: mcpSnapshot,
|
||||
overwrite: true,
|
||||
});
|
||||
expect(closed.isError, JSON.stringify(closed)).not.toBe(true);
|
||||
const mcpSaved = await readAnalysisSnapshot(mcpSnapshot);
|
||||
if (!mcpSaved.ok) throw mcpSaved.error;
|
||||
expect(mcpSaved.value.binding.analysis_profile).toEqual(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
);
|
||||
expect(mcpSaved.value.entries).toEqual([]);
|
||||
expect(mcpSaved.value.workflow_entries).toHaveLength(1);
|
||||
|
||||
const reopened = await call("open_binary", {
|
||||
path: target,
|
||||
snapshot_path: mcpSnapshot,
|
||||
});
|
||||
expect(reopened.isError, JSON.stringify(reopened)).not.toBe(true);
|
||||
const reobserved = await call("inspect_native_dispatch_metadata", {
|
||||
max_records: 100,
|
||||
});
|
||||
expect(reobserved.isError, JSON.stringify(reobserved)).not.toBe(true);
|
||||
expect(parseEvidence(reobserved.structuredContent)).toEqual(mcpEvidence);
|
||||
expect(await readFile(target)).toEqual(Buffer.from(bytes));
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
workflowAnalysisProfile,
|
||||
} from "../../../src/application/InvestigationProviders.js";
|
||||
import {
|
||||
analysisQueryId,
|
||||
createAnalysisSnapshotWorkflowEntry,
|
||||
type AnalysisSnapshot,
|
||||
} from "../../../src/domain/analysisSnapshot.js";
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
createTestProviderRouter,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { isProfilelessNativeDispatchSnapshot } from "../../../src/application/binary/ProfilelessNativeWorkflowSnapshot.js";
|
||||
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
|
||||
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
CAPABILITIES as GHIDRA_CAPABILITIES,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
} from "../../../src/ghidra/GhidraProviderCapabilities.js";
|
||||
import { APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } from "../../../src/native/NativeMacOSProviderMetadata.js";
|
||||
|
||||
const IDENTITY = {
|
||||
id: "snapshot-fixture",
|
||||
@@ -1039,3 +1042,259 @@ describe("workflow snapshot profile and cancellation binding", () => {
|
||||
expect(fixture.starts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
const expectProfilelessWorkflowSnapshotRejections = async (
|
||||
path: string,
|
||||
provider: AnalysisProvider,
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Promise<void> => {
|
||||
const session = createTestBinarySession(provider);
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok) throw opened.error;
|
||||
const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider;
|
||||
const wrongReader = structuredClone(snapshot);
|
||||
const otherReaderProfile = createAnalysisProfile(
|
||||
{ ...readerIdentity, version: "apple-metadata-reader-older" },
|
||||
{
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
reader: "apple-dispatch-metadata",
|
||||
revision: 0,
|
||||
},
|
||||
);
|
||||
wrongReader.binding = {
|
||||
provider: otherReaderProfile.provider,
|
||||
analysis_profile: otherReaderProfile,
|
||||
};
|
||||
expect(
|
||||
isProfilelessNativeDispatchSnapshot(
|
||||
wrongReader,
|
||||
opened.value,
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
),
|
||||
).toBe(false);
|
||||
const rejectedReader = session.importAnalysisSnapshot(wrongReader);
|
||||
expect(rejectedReader.ok).toBe(false);
|
||||
if (!rejectedReader.ok)
|
||||
expect(rejectedReader.error.message).toContain("profile_mismatch");
|
||||
|
||||
const wrongOperation = structuredClone(snapshot);
|
||||
wrongOperation.workflow_entries[0]!.operation = "inspect_native_api";
|
||||
wrongOperation.workflow_entries[0]!.query_id = analysisQueryId(
|
||||
wrongOperation.target,
|
||||
wrongOperation.binding,
|
||||
"inspect_native_api",
|
||||
wrongOperation.workflow_entries[0]!.parameters,
|
||||
);
|
||||
expect(
|
||||
isProfilelessNativeDispatchSnapshot(
|
||||
wrongOperation,
|
||||
opened.value,
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
),
|
||||
).toBe(false);
|
||||
const rejectedOperation = session.importAnalysisSnapshot(wrongOperation);
|
||||
expect(rejectedOperation.ok).toBe(false);
|
||||
if (!rejectedOperation.ok)
|
||||
expect(rejectedOperation.error.message).toContain("profile_mismatch");
|
||||
|
||||
const mixedOperation = "inspect_native_api";
|
||||
const mixedProfile = workflowAnalysisProfile(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
mixedOperation,
|
||||
);
|
||||
const mixedSnapshot = {
|
||||
...snapshot,
|
||||
workflow_entries: [
|
||||
...snapshot.workflow_entries,
|
||||
createAnalysisSnapshotWorkflowEntry({
|
||||
target: snapshot.target,
|
||||
binding: snapshot.binding,
|
||||
operation: mixedOperation,
|
||||
parameters: {},
|
||||
execution: {
|
||||
result: null,
|
||||
rawResult: null,
|
||||
provider: mixedProfile.provider,
|
||||
analysisProfile: mixedProfile,
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
const rejectedMixed = session.importAnalysisSnapshot(mixedSnapshot);
|
||||
expect(
|
||||
isProfilelessNativeDispatchSnapshot(
|
||||
mixedSnapshot as AnalysisSnapshot,
|
||||
opened.value,
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(rejectedMixed.ok).toBe(false);
|
||||
if (!rejectedMixed.ok)
|
||||
expect(rejectedMixed.error.message).toContain("profile_mismatch");
|
||||
|
||||
const deepProfile = createAnalysisProfile(
|
||||
{ id: "ghidra", name: "Ghidra", version: "fixture-version" },
|
||||
{ fixture: "deep-provider-only" },
|
||||
);
|
||||
const deepWorkflowProfile = workflowAnalysisProfile(
|
||||
deepProfile,
|
||||
"inspect_native_dispatch_metadata",
|
||||
);
|
||||
const originalWorkflow = snapshot.workflow_entries[0]!;
|
||||
const deepSnapshot = {
|
||||
...snapshot,
|
||||
binding: {
|
||||
provider: deepProfile.provider,
|
||||
analysis_profile: deepProfile,
|
||||
},
|
||||
workflow_entries: [
|
||||
createAnalysisSnapshotWorkflowEntry({
|
||||
target: snapshot.target,
|
||||
binding: {
|
||||
provider: deepProfile.provider,
|
||||
analysis_profile: deepProfile,
|
||||
},
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
parameters: originalWorkflow.parameters,
|
||||
execution: {
|
||||
result: originalWorkflow.execution.result,
|
||||
rawResult: originalWorkflow.execution.raw_result,
|
||||
provider: deepWorkflowProfile.provider,
|
||||
analysisProfile: deepWorkflowProfile,
|
||||
limitations: originalWorkflow.execution.limitations,
|
||||
...(originalWorkflow.execution.relationship_coverage === null
|
||||
? {}
|
||||
: {
|
||||
relationshipCoverage:
|
||||
originalWorkflow.execution.relationship_coverage,
|
||||
}),
|
||||
locations: originalWorkflow.execution.locations,
|
||||
subject:
|
||||
originalWorkflow.execution.subject === null
|
||||
? null
|
||||
: {
|
||||
path: originalWorkflow.execution.subject.path,
|
||||
sha256: originalWorkflow.execution.subject.sha256,
|
||||
format: originalWorkflow.execution.subject.format,
|
||||
...(originalWorkflow.execution.subject.architecture === null
|
||||
? {}
|
||||
: {
|
||||
architecture:
|
||||
originalWorkflow.execution.subject.architecture,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
} as AnalysisSnapshot;
|
||||
const rejectedDeepOnly = session.importAnalysisSnapshot(deepSnapshot);
|
||||
expect(rejectedDeepOnly.ok).toBe(false);
|
||||
if (!rejectedDeepOnly.ok)
|
||||
expect(rejectedDeepOnly.error.message).toContain("profile_mismatch");
|
||||
};
|
||||
|
||||
it("retains a native dispatch workflow snapshot without a deep provider profile", async () => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-native-dispatch-workflow-",
|
||||
);
|
||||
const identity: ProviderIdentity = {
|
||||
id: "native-macos",
|
||||
name: "macOS native inspection utilities",
|
||||
version: null,
|
||||
};
|
||||
const capabilities: CapabilityDescriptor[] = [
|
||||
{
|
||||
provider: identity,
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
available: true,
|
||||
reason: null,
|
||||
cachePolicy: "snapshot",
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: [],
|
||||
},
|
||||
];
|
||||
const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider;
|
||||
let dispatchCalls = 0;
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => identity,
|
||||
capabilities: () => capabilities,
|
||||
createClient: () => ({
|
||||
execute: async (operation) =>
|
||||
operation === "health"
|
||||
? ok(createAnalysisExecution(null, identity))
|
||||
: operation === "inspect_native_dispatch_metadata"
|
||||
? ok(
|
||||
(dispatchCalls++,
|
||||
createAnalysisExecution(
|
||||
{
|
||||
target_sha256: null,
|
||||
provider: {
|
||||
id: identity.id,
|
||||
name: readerIdentity.name,
|
||||
version: readerIdentity.version,
|
||||
},
|
||||
analysis_profile_digest:
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
|
||||
result: { db_save_result: null },
|
||||
},
|
||||
readerIdentity,
|
||||
{
|
||||
analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
},
|
||||
)),
|
||||
)
|
||||
: err(new ProviderAdapterError(identity.id, operation)),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
};
|
||||
const dependencies = fixture.dependencies(provider);
|
||||
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: 100 },
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
operation: "inspect_native_dispatch_metadata",
|
||||
});
|
||||
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
expect(saved.value.binding.analysis_profile).toEqual(
|
||||
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
|
||||
);
|
||||
expect(saved.value.entries).toEqual([]);
|
||||
expect(saved.value.workflow_entries).toEqual([
|
||||
expect.objectContaining({ operation: "inspect_native_dispatch_metadata" }),
|
||||
]);
|
||||
|
||||
const replay = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: 100 },
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
expect(replay).toEqual(result);
|
||||
expect(dispatchCalls).toBe(1);
|
||||
|
||||
await expectProfilelessWorkflowSnapshotRejections(
|
||||
fixture.path,
|
||||
provider,
|
||||
saved.value,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user