Merge origin/main into fix/fixed-budget-refusal-advice

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-20 15:44:09 +00:00
commit beaf4040bc
30 files changed
+1670 -94

No files matched your search

+2
View File
@@ -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;
+23 -3
View File
@@ -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 = (
+1
View File
@@ -59,6 +59,7 @@ it.each([false, true])(
workflowSnapshotRecord(
evidence,
"inspect_native_api",
ANALYSIS_SNAPSHOT_PROFILE,
composed.value.cachePolicy,
) === undefined,
).toBe(transient);
+7 -1
View File
@@ -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,
+13 -1
View File
@@ -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,
};
+22 -2
View File
@@ -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",
);
+55 -6
View File
@@ -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),
),
);
@@ -145,20 +145,28 @@ const matchedItem = (
const relationships = compareRelationships(pair, context);
const dimensions = changedDimensions(pair, relationships);
const complete = graphsComplete(context);
const ownEvidenceUncertain =
ownPresenceUncertain(pair.left) || ownPresenceUncertain(pair.right);
const observedChange =
dimensions.includes("content") || dimensions.includes("properties");
!ownEvidenceUncertain &&
(dimensions.includes("content") || dimensions.includes("properties"));
// Coordinates remain comparison evidence; relocation alone is not a
// content, property, or relationship change.
const completeChange =
complete && dimensions.some((dimension) => dimension !== "location");
!ownEvidenceUncertain &&
complete &&
dimensions.some((dimension) => dimension !== "location");
const status: ApplicationVersionComparisonItem["status"] =
observedChange || completeChange
? "changed"
: complete && !relationships.unresolved
? "unchanged"
: "unknown";
const finalDimensions =
status === "unknown" && !complete
ownEvidenceUncertain
? "unknown"
: observedChange || completeChange
? "changed"
: complete && !relationships.unresolved
? "unchanged"
: "unknown";
const finalDimensions = ownEvidenceUncertain
? []
: status === "unknown" && !complete
? [...dimensions, "coverage" as const]
: dimensions;
const semantic: ComparisonItemSemantic = {
@@ -170,6 +178,10 @@ const matchedItem = (
dimensions: finalDimensions,
evidence_links: itemEvidenceLinks(pair.left, pair.right, context),
limitations: [
...new Set([
...ownAvailabilityLimitations(pair.left),
...ownAvailabilityLimitations(pair.right),
]),
...matchLimitations(pair),
...(status === "unknown" && !complete
? [
@@ -218,7 +230,20 @@ const leftOnlyItem = (
candidate_right_node_ids: [firstCandidate, ...remainingCandidates],
},
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
limitations: [
...ownAvailabilityLimitations(node),
AMBIGUOUS_MATCH_LIMITATION,
],
});
if (ownPresenceUncertain(node))
return itemWithId({
...common,
status: "unknown",
left_node_id: node.node_id,
right_node_id: null,
match: unmatchedMatch(),
dimensions: ["availability"],
limitations: ownAvailabilityLimitations(node),
});
const absenceObserved = absenceClaimable(context.rightGraph.coverage);
return itemWithId({
@@ -230,7 +255,10 @@ const leftOnlyItem = (
dimensions: absenceObserved
? ["availability"]
: ["availability", "coverage"],
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
limitations: [
...ownAvailabilityLimitations(node),
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
],
});
};
@@ -258,7 +286,20 @@ const rightOnlyItem = (
candidate_right_node_ids: [],
},
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
limitations: [
...ownAvailabilityLimitations(node),
AMBIGUOUS_MATCH_LIMITATION,
],
});
if (ownPresenceUncertain(node))
return itemWithId({
...common,
status: "unknown",
left_node_id: null,
right_node_id: node.node_id,
match: unmatchedMatch(),
dimensions: ["availability"],
limitations: ownAvailabilityLimitations(node),
});
const absenceObserved = absenceClaimable(context.leftGraph.coverage);
return itemWithId({
@@ -270,7 +311,10 @@ const rightOnlyItem = (
dimensions: absenceObserved
? ["availability"]
: ["availability", "coverage"],
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
limitations: [
...ownAvailabilityLimitations(node),
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
],
});
};
@@ -331,6 +375,21 @@ const AMBIGUOUS_MATCH_LIMITATION =
const INCOMPLETE_GRAPH_LIMITATION =
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
const ownPresenceUncertain = (node: ApplicationNode): boolean =>
node.observations.every(
({ evidence }) =>
evidence.state === "unknown" || evidence.state === "unavailable",
);
const ownAvailabilityLimitations = (node: ApplicationNode): string[] => {
const limitations = new Set<string>();
for (const { evidence } of node.observations)
if (evidence.state === "unknown" || evidence.state === "unavailable")
for (const limitation of evidence.limitations)
limitations.add(limitation);
return [...limitations].sort(compareUnicodeCodePoints);
};
const changedDimensions = (
pair: ApplicationVersionNodePair,
relationships: RelationshipComparison,
@@ -0,0 +1,207 @@
import { expect, it } from "vitest";
import {
createJavaScriptApplicationGraph,
createJavaScriptApplicationNode,
} from "./javascriptApplicationGraph.js";
import {
artifactEvidence,
completeCoverage,
} from "./javascriptApplicationGraph.fixture.js";
import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js";
const LEFT_SHA = "a".repeat(64);
const RIGHT_SHA = "b".repeat(64);
const LIMITATION =
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.";
const evidenceId = (character: string) => `ev_${character.repeat(64)}`;
const node = (sha256: string, path: string) =>
createJavaScriptApplicationNode({
kind: "javascript-asset",
identity: { strategy: "content-digest", stability: "global-exact", sha256 },
observations: [
{
label: path,
properties: { path },
evidence: artifactEvidence(sha256, path),
},
],
});
const unavailableScope = (
artifactSha: string,
state: "unknown" | "unavailable" = "unavailable",
) =>
createJavaScriptApplicationNode({
kind: "unknown",
identity: {
strategy: "artifact-local-key",
stability: "artifact-version",
artifact_sha256: artifactSha,
namespace: "static-parse-scope",
key: "select-javascript-sources:lib.js",
},
observations: [
{
label: "lib.js parse scope",
properties: { path: "lib.js", operation: "select-javascript-sources" },
evidence: {
...artifactEvidence(artifactSha, "lib.js"),
state,
confidence: "unknown",
limitations: [LIMITATION],
},
},
],
});
const observedAssetWithUnavailableFact = () => {
const digest = "e".repeat(64);
return createJavaScriptApplicationNode({
kind: "javascript-asset",
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: digest,
},
observations: [
{
label: "new.js",
properties: { path: "new.js" },
evidence: artifactEvidence(digest, "new.js"),
},
{
label: "new.js parse scope",
properties: { operation: "parse-javascript" },
evidence: {
...artifactEvidence(digest, "new.js"),
state: "unavailable",
confidence: "unknown",
limitations: [LIMITATION],
},
},
],
});
};
const graph = (nodes: ReturnType<typeof node>[]) =>
createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: [nodes[0]!.node_id],
nodes,
edges: [],
coverage: completeCoverage,
limitations: [],
});
const compare = (
left: ReturnType<typeof node>[],
right: ReturnType<typeof node>[],
) =>
compareJavaScriptApplicationVersions({
left: {
evidenceId: evidenceId("1"),
rootArtifactSha256: LEFT_SHA,
graph: graph(left),
},
right: {
evidenceId: evidenceId("2"),
rootArtifactSha256: RIGHT_SHA,
graph: graph(right),
},
leftNativeEvidence: [],
rightNativeEvidence: [],
});
it("keeps unmatched unavailable scopes unknown in either direction", () => {
const leftRoot = node("d".repeat(64), "main.js");
const rightRoot = node("d".repeat(64), "main.js");
const leftScope = unavailableScope(LEFT_SHA);
const rightScope = unavailableScope(RIGHT_SHA);
const forward = compare([leftRoot], [rightRoot, rightScope]);
const reverse = compare([leftRoot, leftScope], [rightRoot]);
expect(
forward.items.find(
({ right_node_id }) => right_node_id === rightScope.node_id,
),
).toMatchObject({
status: "unknown",
dimensions: ["availability"],
limitations: [LIMITATION],
evidence_links: [evidenceId("1"), evidenceId("2")],
});
expect(
reverse.items.find(
({ left_node_id }) => left_node_id === leftScope.node_id,
),
).toMatchObject({
status: "unknown",
dimensions: ["availability"],
limitations: [LIMITATION],
});
expect(forward.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 });
expect(reverse.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 });
});
it("keeps matched unavailable scopes unknown with their source limitation", () => {
const leftRoot = node("d".repeat(64), "main.js");
const rightRoot = node("d".repeat(64), "main.js");
const leftScope = unavailableScope(LEFT_SHA);
const rightScope = unavailableScope(LEFT_SHA);
const result = compare([leftRoot, leftScope], [rightRoot, rightScope]);
expect(
result.items.find(({ left_node_id }) => left_node_id === leftScope.node_id),
).toMatchObject({
right_node_id: rightScope.node_id,
status: "unknown",
dimensions: [],
limitations: [LIMITATION],
});
expect(result.summary).toMatchObject({ unchanged: 1, unknown: 1 });
});
it("retains unknown scope facts and reports genuine entity changes", () => {
const leftRoot = node("d".repeat(64), "main.js");
const rightRoot = node("d".repeat(64), "main.js");
const unknown = unavailableScope(RIGHT_SHA, "unknown");
const added = node("e".repeat(64), "new.js");
const removed = node("f".repeat(64), "removed.js");
const mixedEvidenceAddition = observedAssetWithUnavailableFact();
const unknownResult = compare([leftRoot], [rightRoot, unknown]);
const addedResult = compare([leftRoot], [rightRoot, added]);
const removedResult = compare([leftRoot, removed], [rightRoot]);
const mixedResult = compare([leftRoot], [rightRoot, mixedEvidenceAddition]);
expect(
unknownResult.items.find(
({ right_node_id }) => right_node_id === unknown.node_id,
),
).toMatchObject({ status: "unknown", limitations: [LIMITATION] });
expect(unknownResult.summary).toMatchObject({ added: 0, unknown: 1 });
expect(
addedResult.items.find(
({ right_node_id }) => right_node_id === added.node_id,
),
).toMatchObject({ status: "added", dimensions: ["availability"] });
expect(addedResult.summary.added).toBe(1);
expect(
mixedResult.items.find(
({ right_node_id }) => right_node_id === mixedEvidenceAddition.node_id,
),
).toMatchObject({ status: "added", limitations: [LIMITATION] });
expect(mixedResult.summary.added).toBe(1);
expect(
removedResult.items.find(
({ left_node_id }) => left_node_id === removed.node_id,
),
).toMatchObject({ status: "removed", dimensions: ["availability"] });
expect(removedResult.summary.removed).toBe(1);
expect(
compare(
[leftRoot, node("c".repeat(64), "same.js")],
[rightRoot, node("c".repeat(64), "same.js")],
).summary,
).toMatchObject({
unchanged: 2,
added: 0,
removed: 0,
});
});
+7 -1
View File
@@ -38,7 +38,13 @@ export const classifySwiftSymbol = (
let category: SwiftTypeCategory | null = null;
for (;;) {
const end = identifierEnd(mangling, offset);
if (end === undefined) break;
if (end === undefined) {
// A zero-prefixed identifier uses word substitutions or Punycode. We
// cannot tell whether it continues a nested type or names a member, so
// retaining the outer category would be a guess.
if (mangling.charAt(offset) === "0") category = null;
break;
}
const kind = mangling.charAt(end);
const nominal = NOMINAL_CATEGORIES.get(kind);
if (nominal === undefined) {
+50
View File
@@ -52,6 +52,56 @@ describe("modern Swift category evidence", () => {
).toMatchObject({ total: 0, unclassified: [] });
});
it("preserves unsupported zero-prefixed nested Unicode contexts as unresolved", () => {
const names = [
"_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF",
"_$s6Nested8EnvelopeV007jvrtDBlCAEycfC",
"_$s6Nested8EnvelopeV007jvrtDBlCMa",
];
const procedures = names.map((name, index) => ({
address: `0x${String(index + 1)}`,
name,
}));
const unresolved = names.map((name) => ({
name,
mangled_names: [name],
reason: "category_not_decoded",
}));
expect(categorizeSwiftTypes(procedures)).toMatchObject({
total: 3,
categories: {
other: {
items: names.map((name) => ({ name, mangled_names: [name] })),
},
},
unclassified: unresolved,
});
for (const category of ["classes", "enums"] as const)
expect(categorizeSwiftTypes(procedures, { category })).toMatchObject({
total: 0,
categories: { [category]: { count: 0 }, other: { count: 0 } },
unclassified: unresolved,
});
});
it("keeps outer and ASCII nested nominal categories decoded", () => {
const names = [
["_$s6Nested8EnvelopeVMa", "structs"],
["_$s6Nested8EnvelopeV5InnerCMa", "classes"],
["_$s6Nested8EnvelopeV5InnerC6methodyyF", "classes"],
] as const;
for (const [name, category] of names) {
expect(
categorizeSwiftTypes([{ address: "0x1", name }], { category }),
).toMatchObject({
total: 1,
categories: { [category]: { count: 1 } },
unclassified: [],
});
}
});
it("deduplicates display names after selecting a category", () => {
expect(
categorizeSwiftTypes(
+182
View File
@@ -7,6 +7,7 @@ import {
nativeMachoTarget,
} from "../../tests/fixtures/nativeCommands.js";
import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js";
import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
@@ -128,6 +129,20 @@ const fixture = (
};
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
const withProtocolListSection = (bytes: Buffer): Buffer => {
const pointer = (at: number, offset: number) =>
bytes.writeBigUInt64LE(base + BigInt(offset), at);
bytes.writeUInt32LE(232, 20);
bytes.writeUInt32LE(232, 36);
bytes.writeUInt32LE(2, 96);
bytes.write("__objc_protolist", 184);
bytes.write("__DATA", 200);
pointer(216, 0x6c0);
bytes.writeBigUInt64LE(8n, 224);
pointer(0x6c0, 0x740);
return bytes;
};
const partialSiblingsFixture = (
options: {
readonly emptyMiddleClass?: boolean;
@@ -478,6 +493,173 @@ describe("Apple dispatch coverage ownership", () => {
});
});
describe("Objective-C declaration-local record-budget coverage", () => {
it.each(["protocol", "class"] as const)(
"preserves the decoded $0 method at a sufficient budget",
(owner) => {
const result = decodeAppleDispatchMetadata(
objcRecordBudgetMacho(owner),
100,
provenance,
);
if (owner === "protocol")
expect(result.objc_protocol_records).toMatchObject([
{
name: "FixtureProtocol",
methods: [{ selector: "protocolMethod" }],
decode: { status: "decoded", reason: null },
},
]);
else
expect(result.objc_classes).toMatchObject([
{
name: "FixtureClass",
methods: [{ selector: "classMethod", method_type: "instance" }],
decode: { status: "decoded", reason: null },
},
]);
},
);
it("marks an omitted protocol method list partial while preserving known emptiness", () => {
const truncated = decodeAppleDispatchMetadata(
withProtocolListSection(fixture()),
1,
provenance,
);
expect(truncated.objc_protocol_records).toMatchObject([
{
name: "FixtureProtocol",
methods: [],
decode: { status: "partial", reason: "max_records_reached" },
},
]);
const emptyBytes = withProtocolListSection(fixture());
emptyBytes.writeBigUInt64LE(0n, 0x758);
emptyBytes.writeBigUInt64LE(0n, 0x768);
const empty = decodeAppleDispatchMetadata(emptyBytes, 1, provenance);
expect(empty.objc_protocol_records).toMatchObject([
{
name: "FixtureProtocol",
methods: [],
optional_methods: [],
decode: { status: "decoded", reason: null },
},
]);
});
it("marks omitted class methods, ivars, properties, and adopted protocols partial", () => {
const methodBytes = fixture();
methodBytes.writeUInt32LE(2, 0x300);
methodBytes.writeBigUInt64LE(0n, 0x330);
methodBytes.writeBigUInt64LE(0n, 0x340);
const methodLimited = decodeAppleDispatchMetadata(
methodBytes,
1,
provenance,
);
expect(methodLimited.objc_classes[0]).toMatchObject({
name: "Fixture",
methods: [],
decode: { status: "partial", reason: "max_records_reached" },
});
const ivarBytes = fixture();
ivarBytes.writeBigUInt64LE(0n, 0x320);
ivarBytes.writeBigUInt64LE(0n, 0x328);
ivarBytes.writeBigUInt64LE(0n, 0x340);
ivarBytes.writeUInt32LE(2, 0x300);
const ivarsLimited = decodeAppleDispatchMetadata(ivarBytes, 1, provenance);
expect(ivarsLimited.objc_classes[0]).toMatchObject({
name: "Fixture",
ivar_count: 1,
decode: { status: "partial", reason: "max_records_reached" },
});
expect(ivarsLimited.objc_ivars).toEqual([]);
const propertyBytes = fixture();
propertyBytes.writeBigUInt64LE(0n, 0x320);
propertyBytes.writeBigUInt64LE(0n, 0x328);
propertyBytes.writeBigUInt64LE(0n, 0x330);
propertyBytes.writeUInt32LE(2, 0x300);
const propertiesLimited = decodeAppleDispatchMetadata(
propertyBytes,
1,
provenance,
);
expect(propertiesLimited.objc_classes[0]).toMatchObject({
name: "Fixture",
properties: [],
decode: { status: "partial", reason: "max_records_reached" },
});
const adoptedBytes = fixture();
adoptedBytes.writeBigUInt64LE(0n, 0x320);
adoptedBytes.writeBigUInt64LE(0n, 0x330);
adoptedBytes.writeBigUInt64LE(0n, 0x340);
adoptedBytes.writeUInt32LE(2, 0x300);
const adoptedLimited = decodeAppleDispatchMetadata(
adoptedBytes,
1,
provenance,
);
expect(adoptedLimited.objc_classes[0]).toMatchObject({
name: "Fixture",
protocols: [],
decode: { status: "partial", reason: "max_records_reached" },
});
});
it("keeps genuinely empty class method lists decoded at the record limit", () => {
const bytes = fixture();
bytes.writeBigUInt64LE(0n, 0x320);
bytes.writeBigUInt64LE(0n, 0x328);
bytes.writeBigUInt64LE(0n, 0x330);
bytes.writeBigUInt64LE(0n, 0x340);
bytes.writeUInt32LE(2, 0x300);
const result = decodeAppleDispatchMetadata(bytes, 1, provenance);
expect(result.objc_classes[0]).toMatchObject({
name: "Fixture",
methods: [],
decode: { status: "decoded", reason: null },
});
});
});
describe("Objective-C category-local record-budget coverage", () => {
it.each(["instance", "class"] as const)(
"marks category %s methods partial only when a declared entry is omitted",
(methodType) => {
const bytes = fixture(false, "partial-method");
const listField = methodType === "instance" ? 0xb50 : 0xb58;
const methodList = bytes.readBigUInt64LE(0xb50);
bytes.writeBigUInt64LE(0n, 0xb50);
bytes.writeBigUInt64LE(methodList, listField);
bytes.writeUInt32LE(2, 0x300);
bytes.writeBigUInt64LE(0n, 0x320);
const truncated = decodeAppleDispatchMetadata(bytes, 11, provenance);
expect(truncated.objc_categories).toMatchObject([
{
name: "FixtureCategory",
[`${methodType}_methods`]: [],
decode: { status: "partial", reason: "max_records_reached" },
},
]);
bytes.writeBigUInt64LE(0n, listField);
const empty = decodeAppleDispatchMetadata(bytes, 11, provenance);
expect(empty.objc_categories).toMatchObject([
{
name: "FixtureCategory",
[`${methodType}_methods`]: [],
decode: { status: "decoded", reason: null },
},
]);
},
);
});
describe("malformed Objective-C property records", () => {
it.each([
["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"],
+6 -7
View File
@@ -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";
@@ -256,12 +259,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,
+46 -23
View File
@@ -31,8 +31,9 @@ export const decodeObjcCategories = (input: {
className: string,
methodType: "instance" | "class",
category: string,
onTruncated?: () => void,
) => string[];
readonly protocols: (list: bigint) => string[];
readonly protocols: (list: bigint, onTruncated?: () => void) => string[];
/** Name of a class defined in this image, from its class_ro_t. */
readonly localClassName: (address: bigint) => string;
}): number => {
@@ -71,6 +72,43 @@ export const decodeObjcCategories = (input: {
);
}
const owner = className ?? `(${name})`;
let budgetTruncated = false;
const onTruncated = () => {
budgetTruncated = true;
};
const instanceMethods = input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
onTruncated,
);
const classMethods = input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
onTruncated,
);
const protocols = input.protocols(
read.pointer(category + 32n),
onTruncated,
);
const properties = readObjcPropertiesOf(
read,
read.pointer(category + 40n),
{
owner: `category ${name}`,
admit: input.admit,
issues: input.issues,
location: `0x${read.pointer(category + 40n).toString(16)}`,
onTruncated,
},
);
const decodeReasons = [
...(className === null ? ["category_class_unresolved"] : []),
...(budgetTruncated ? ["max_records_reached"] : []),
];
result.objc_categories.push({
name,
class_name: className,
@@ -80,30 +118,15 @@ export const decodeObjcCategories = (input: {
: className === null
? "unresolved"
: "local",
instance_methods: input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
),
class_methods: input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
),
protocols: input.protocols(read.pointer(category + 32n)),
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
owner: `category ${name}`,
admit: input.admit,
issues: input.issues,
location: `0x${read.pointer(category + 40n).toString(16)}`,
}),
instance_methods: instanceMethods,
class_methods: classMethods,
protocols,
properties,
location: read.location(category),
decode:
className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
decodeReasons.length === 0
? { status: "decoded", reason: null }
: { status: "partial", reason: decodeReasons.join("; ") },
evidence: read.evidence(category, "Objective-C category_t record"),
});
} catch (cause: unknown) {
+40 -14
View File
@@ -103,6 +103,7 @@ export const decodeObjcDispatchFacets = (input: {
className: string,
methodType: "instance" | "class",
category?: string,
onTruncated?: () => void,
): string[] => {
const selectors: string[] = [];
if (list === 0n) return selectors;
@@ -113,7 +114,10 @@ export const decodeObjcDispatchFacets = (input: {
if (stride !== (relative ? 12 : 24))
throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`);
for (let index = 0; index < count; index++) {
if (!budget.admit()) break;
if (!budget.admit()) {
onTruncated?.();
break;
}
const entry = list + 8n + BigInt(index * stride);
const rel = (field: bigint) => {
const displacement = bytes.readInt32LE(read.offset(field, 4));
@@ -279,6 +283,10 @@ export const decodeObjcDispatchFacets = (input: {
);
const ivarList = read.pointer(ro + 48n);
let ivarCount: number | null = meta ? null : 0;
let classListTruncated = false;
const onTruncated = () => {
classListTruncated = true;
};
if (ivarList !== 0n && !meta) {
const stride = read.u32(ivarList),
count = read.u32(ivarList + 4n);
@@ -286,7 +294,10 @@ export const decodeObjcDispatchFacets = (input: {
throw new TypeError("Unsupported Objective-C ivar entry size");
ivarCount = count;
for (let index = 0; index < count; index++) {
if (!budget.admit()) break;
if (!budget.admit()) {
classListTruncated = true;
break;
}
const entry = ivarList + 8n + BigInt(index * stride);
const encodedOffset = read.pointer(entry);
let value: number | null = null;
@@ -326,7 +337,27 @@ export const decodeObjcDispatchFacets = (input: {
});
}
}
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
methods(
read.pointer(ro + 32n),
name,
meta ? "class" : "instance",
undefined,
onTruncated,
);
const properties = readObjcPropertiesOf(read, read.pointer(ro + 64n), {
owner: name,
admit: () => budget.admit(),
issues: categoryIssues,
location: hex(read.pointer(ro + 64n)),
onTruncated,
});
const protocols = protocolReader.list(read.pointer(ro + 40n), onTruncated);
const classDecodeReasons = [
...(superclass.name === null && !root
? ["superclass_binding_unresolved"]
: []),
...(classListTruncated ? ["max_records_reached"] : []),
];
result.objc_classes.push({
name,
super_class: superclass.name,
@@ -350,22 +381,17 @@ export const decodeObjcDispatchFacets = (input: {
is_required: null,
is_optional: null,
})),
properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), {
owner: name,
admit: () => budget.admit(),
issues: categoryIssues,
location: hex(read.pointer(ro + 64n)),
}),
protocols: protocolReader.list(read.pointer(ro + 40n)),
properties,
protocols,
ivar_count: ivarCount,
instance_size: read.u32(ro + 8n),
location: read.location(address),
superclass_address: superclass.address,
metaclass_address: meta ? null : hex(read.pointer(address)),
decode:
superclass.name === null && !root
? { status: "partial", reason: "superclass_binding_unresolved" }
: decoded,
classDecodeReasons.length === 0
? decoded
: { status: "partial", reason: classDecodeReasons.join("; ") },
evidence: read.evidence(
address,
"Objective-C class and class_ro_t records",
@@ -422,7 +448,7 @@ export const decodeObjcDispatchFacets = (input: {
issues: categoryIssues,
admit: () => budget.admit(),
methods,
protocols: (list) => protocolReader.list(list),
protocols: (list, onTruncated) => protocolReader.list(list, onTruncated),
localClassName: (address) => readLocalClass(read, address).name,
});
return {
+7 -1
View File
@@ -107,6 +107,7 @@ export const readObjcPropertyList = (
read: ObjcPropertyReaders,
list: bigint,
admit: () => boolean,
onTruncated?: () => void,
): {
readonly properties: ObjcProperty[];
readonly issues: PropertyListIssue[];
@@ -121,7 +122,10 @@ export const readObjcPropertyList = (
`Unsupported Objective-C property entry size ${entrySize}`,
);
for (let index = 0; index < count; index++) {
if (!admit()) break;
if (!admit()) {
onTruncated?.();
break;
}
const entry = list + 8n + BigInt(index * entrySize);
const location = `0x${entry.toString(16)}`;
try {
@@ -158,6 +162,7 @@ export const readObjcPropertiesOf = (
readonly admit: () => boolean;
readonly issues: DecodeIssue[];
readonly location?: string;
readonly onTruncated?: () => void;
},
): ObjcProperty[] => {
try {
@@ -165,6 +170,7 @@ export const readObjcPropertiesOf = (
read,
list,
context.admit,
context.onTruncated,
);
for (const item of issues)
context.issues.push(
+49 -10
View File
@@ -18,7 +18,7 @@ export const createObjcProtocolReader = (input: {
const { readers: read, result, failures } = input;
const visited = new Set<bigint>();
const name = (address: bigint) => read.string(read.pointer(address + 8n));
const list = (address: bigint): string[] => {
const list = (address: bigint, onTruncated?: () => void): string[] => {
if (address === 0n) return [];
// `count` is plain uintptr_t data, not a pointer fixup.
const count = read.u64(address);
@@ -26,7 +26,10 @@ export const createObjcProtocolReader = (input: {
throw new RangeError("Protocol list exceeds 20000 entries");
const names: string[] = [];
for (let index = 0n; index < count; index++) {
if (!read.admit()) break;
if (!read.admit()) {
onTruncated?.();
break;
}
const protocol = read.pointer(address + 8n + index * 8n);
names.push(name(protocol));
record(protocol);
@@ -39,6 +42,7 @@ export const createObjcProtocolReader = (input: {
instance: boolean,
/** Entries skipped for an empty selector, reported by the declaring protocol. */
emptySelectors: string[],
onTruncated?: () => void,
) => {
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
if (address === 0n) return output;
@@ -49,7 +53,10 @@ export const createObjcProtocolReader = (input: {
if (stride !== (relative ? 12 : 24))
throw new TypeError("Unsupported Objective-C protocol method entry size");
for (let index = 0; index < count; index++) {
if (!read.admit()) break;
if (!read.admit()) {
onTruncated?.();
break;
}
const entry = address + 8n + BigInt(index * stride);
let selector = relative
? entry + BigInt(read.i32(entry))
@@ -84,22 +91,54 @@ export const createObjcProtocolReader = (input: {
const protocolName = name(address);
// Adopted protocols are independent declarations; decode them before
// rejecting a parent whose own name is empty.
const adopted = list(read.pointer(address + 16n));
let budgetTruncated = false;
const onTruncated = () => {
budgetTruncated = true;
};
const adopted = list(read.pointer(address + 16n), onTruncated);
if (protocolName.length === 0)
throw new TypeError("Objective-C protocol has an empty name");
const skipped: string[] = [];
const required = [
...methods(read.pointer(address + 24n), true, true, skipped),
...methods(read.pointer(address + 32n), true, false, skipped),
...methods(
read.pointer(address + 24n),
true,
true,
skipped,
onTruncated,
),
...methods(
read.pointer(address + 32n),
true,
false,
skipped,
onTruncated,
),
];
const optional = [
...methods(read.pointer(address + 40n), false, true, skipped),
...methods(read.pointer(address + 48n), false, false, skipped),
...methods(
read.pointer(address + 40n),
false,
true,
skipped,
onTruncated,
),
...methods(
read.pointer(address + 48n),
false,
false,
skipped,
onTruncated,
),
];
for (const message of skipped)
failures.push(
`Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} ${message}`,
);
const decodeReasons = [
...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]),
...(budgetTruncated ? ["max_records_reached"] : []),
];
result.objc_protocols.push({
name: protocolName,
methods: required,
@@ -113,9 +152,9 @@ export const createObjcProtocolReader = (input: {
optional_methods: optional,
location: read.location(address),
decode:
skipped.length === 0
decodeReasons.length === 0
? { status: "decoded", reason: null }
: { status: "partial", reason: "protocol_method_selector_empty" },
: { status: "partial", reason: decodeReasons.join("; ") },
evidence: read.evidence(
address,
"Objective-C protocol declaration and encoded method lists; IMP fields are not handler targets",
+2
View File
@@ -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],
+17
View File
@@ -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,
+4 -1
View File
@@ -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
+5 -2
View File
@@ -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) {
@@ -9,6 +9,113 @@ import { applicationVersionComparisonResultSchema } from "../../../src/domain/ja
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"keeps unavailable source-selection scopes unknown in version comparisons",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-version-scope-");
const application = join(root, "application");
const copy = join(root, "copy");
await Promise.all([mkdir(application), mkdir(copy)]);
const files = [
["lib.js", "export function shared() { return 1; }\n"],
["main.js", 'export const name = "one";\n'],
] as const;
for (const [name, contents] of files) {
await Promise.all([
writeFile(join(application, name), contents),
writeFile(join(copy, name), contents),
]);
}
const analyze = async (input: string, selected: boolean) => {
const response = await cli.run({
arguments: [
"analyze-javascript-application",
input,
...(selected ? ["--source-path", "main.js"] : []),
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
return parseEvidence(response.json);
};
const full = await analyze(application, false);
const selected = await analyze(application, true);
const fullCopy = await analyze(copy, false);
const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse(
selected.normalized_result,
);
const parseScope = selectedAnalysis.graph.nodes.find(
({ kind, observations }) =>
kind === "unknown" &&
observations.some(
({ properties }) =>
properties.path === "lib.js" &&
properties.operation === "select-javascript-sources",
),
);
expect(parseScope?.observations[0]?.evidence).toMatchObject({
state: "unavailable",
limitations: [
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
],
});
const compare = async (
left: ReturnType<typeof parseEvidence>,
right: ReturnType<typeof parseEvidence>,
name: string,
) => {
const inputPath = join(root, `${name}.json`);
await writeFile(inputPath, JSON.stringify({ left, right }));
const response = await cli.run({
arguments: ["compare-application-versions", inputPath, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
return applicationVersionComparisonResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
};
const forward = await compare(full, selected, "forward");
const reverse = await compare(selected, full, "reverse");
const unchangedCopy = await compare(full, fullCopy, "full-copy");
expect(
forward.items.find(
({ right_node_id }) => right_node_id === parseScope?.node_id,
),
).toMatchObject({
status: "unknown",
dimensions: ["availability"],
limitations: [
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
],
evidence_links: expect.arrayContaining([
full.evidence_id,
selected.evidence_id,
]),
});
expect(
reverse.items.find(
({ left_node_id }) => left_node_id === parseScope?.node_id,
),
).toMatchObject({
status: "unknown",
dimensions: ["availability"],
limitations: [
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
],
});
expect(forward.summary.added).toBe(0);
expect(forward.summary.removed).toBe(0);
expect(reverse.summary.added).toBe(0);
expect(reverse.summary.removed).toBe(0);
expect(unchangedCopy.summary).toMatchObject({ added: 0, removed: 0 });
},
);
cliTest(
"uses syntax-aware bundle fingerprints for changed syntax and renamed bindings",
async ({ cli }) => {
@@ -25,6 +25,7 @@ import {
javascriptApplicationAnalysisResultSchema,
type JavaScriptApplicationAnalysisResult,
} from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
import { z } from "zod";
@@ -186,23 +187,31 @@ const expectSelfImportEvidence = (
const analyzeThroughStdioMcp = async (
inputPath: string,
): Promise<JavaScriptApplicationAnalysisResult> => {
const evidence = await analyzeEvidenceThroughStdioMcp(inputPath);
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
expect(result).toMatchObject({ input_path: inputPath, format: "directory" });
return result;
};
const analyzeEvidenceThroughStdioMcp = async (
inputPath: string,
sourcePaths?: readonly string[],
) => {
const client = await connectedMcpClient();
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: inputPath },
arguments: {
input_path: inputPath,
...(sourcePaths === undefined ? {} : { source_paths: [...sourcePaths] }),
},
});
expect(response.isError).not.toBe(true);
expect(response.content).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "text" })]),
);
const result = z
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
.parse(response.structuredContent).normalized_result;
expect(result).toMatchObject({
input_path: inputPath,
format: "directory",
});
return result;
return parseEvidence(response.structuredContent);
};
const expectCliAnalysisParity = async (
@@ -1220,6 +1229,73 @@ describe("application workflow CLI input", () => {
});
});
describe("application version source-scope MCP comparison", () => {
it("keeps unselected parse scopes unknown through stdio MCP", async () => {
const root = await createTestTempDirectory("rea-version-scope-mcp-");
await Promise.all([
writeFile(
join(root, "lib.js"),
"export function shared() { return 1; }\n",
),
writeFile(join(root, "main.js"), 'export const name = "one";\n'),
]);
const full = await analyzeEvidenceThroughStdioMcp(root);
const selected = await analyzeEvidenceThroughStdioMcp(root, ["main.js"]);
const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse(
selected.normalized_result,
);
const scope = selectedAnalysis.graph.nodes.find(
({ kind, observations }) =>
kind === "unknown" &&
observations.some(
({ properties }) =>
properties.path === "lib.js" &&
properties.operation === "select-javascript-sources",
),
);
expect(scope?.observations[0]?.evidence).toMatchObject({
state: "unavailable",
limitations: [
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
],
});
const client = await connectedMcpClient();
const compare = async (left: unknown, right: unknown) => {
const response = await client.callTool({
name: "compare_application_versions",
arguments: { left, right },
});
expect(response.isError).not.toBe(true);
return applicationVersionComparisonResultSchema.parse(
parseEvidence(response.structuredContent).normalized_result,
);
};
const forward = await compare(full, selected);
const reverse = await compare(selected, full);
expect(
forward.items.find(
({ right_node_id }) => right_node_id === scope?.node_id,
),
).toMatchObject({
status: "unknown",
dimensions: ["availability"],
limitations: [
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
],
evidence_links: expect.arrayContaining([
full.evidence_id,
selected.evidence_id,
]),
});
expect(
reverse.items.find(({ left_node_id }) => left_node_id === scope?.node_id),
).toMatchObject({ status: "unknown", dimensions: ["availability"] });
expect(forward.summary).toMatchObject({ added: 0, removed: 0 });
expect(reverse.summary).toMatchObject({ added: 0, removed: 0 });
});
});
describe("application workflow CLI copy boundaries", () => {
it("compares source values excluded from escaped rest and spread copies", async () => {
const root = await createTestTempDirectory("rea-copy-boundary-cli-");
@@ -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,
);
@@ -0,0 +1,64 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js";
import { objcRecordBudgetMacho } from "../../fixtures/objcRecordBudgetMacho.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
describe.skipIf(process.platform !== "darwin")(
"native dispatch declaration-local record budget",
() => {
cliTest.for([["protocol"], ["class"]] as const)(
"marks an omitted $0 method list partial in public CLI output",
async ([owner], { cli }) => {
const directory = await createTestTempDirectory("rea-objc-budget-");
const path = join(directory, "fixture.macho");
await writeFile(path, objcRecordBudgetMacho(owner));
const output = await cli.run({
arguments: [
"inspect-native-dispatch-metadata",
path,
"--max-records",
"1",
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0);
const result = nativeDispatchMetadataResultSchema.parse(
parseEvidence(output.json).normalized_result,
).result;
if (owner === "protocol") {
expect(result?.objc_protocol_records).toMatchObject([
{
name: "FixtureProtocol",
methods: [],
decode: { status: "partial", reason: "max_records_reached" },
},
]);
} else {
expect(result?.objc_classes).toMatchObject([
{
name: "FixtureClass",
methods: [],
decode: { status: "partial", reason: "max_records_reached" },
},
]);
}
expect(result?.coverage).toEqual(
expect.arrayContaining([
expect.objectContaining({
facet: "objc_class_method_ivar_metadata",
status: "partial",
reason: expect.stringContaining("max_records_reached"),
}),
]),
);
},
);
},
);
@@ -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,
);
});
@@ -158,3 +158,72 @@ describe("Swift classification through MCP", () => {
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
});
});
describe("Swift category uncertainty through MCP", () => {
it("keeps unsupported nested Unicode names unresolved through MCP filters", async () => {
const names = [
"_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF",
"_$s6Nested8EnvelopeV007jvrtDBlCAEycfC",
"_$s6Nested8EnvelopeV007jvrtDBlCMa",
];
const procedures = [
...names.map((name, index) => ({
address: `0x${String(index + 1)}`,
value: name,
})),
{ address: "0x10", value: "_$s6Nested8EnvelopeVMa" },
{ address: "0x11", value: "_$s6Nested8EnvelopeV5InnerCMa" },
{ address: "0x12", value: "_$s6Nested8EnvelopeV5InnerC6methodyyF" },
];
const client = await connect({
execute: async (operation) =>
ok(operation === "list_procedures" ? procedures : []),
});
const all = jsonResult(
await client.callTool({
name: "analyze_swift_types",
arguments: {},
}),
);
expect(all).toMatchObject({
total: 6,
categories: {
structs: {
items: [{ name: "_$s6Nested8EnvelopeVMa" }],
},
classes: {
items: [
{ name: "_$s6Nested8EnvelopeV5InnerCMa" },
{ name: "_$s6Nested8EnvelopeV5InnerC6methodyyF" },
],
},
other: {
items: names.map((name) => ({ name, mangled_names: [name] })),
},
},
unclassified: names.map((name) => ({
name,
mangled_names: [name],
reason: "category_not_decoded",
})),
});
for (const category of ["classes", "enums"] as const) {
const filtered = jsonResult(
await client.callTool({
name: "analyze_swift_types",
arguments: { category },
}),
);
expect(filtered).toMatchObject({
categories: { [category]: { count: category === "classes" ? 2 : 0 } },
unclassified: names.map((name) => ({
name,
mangled_names: [name],
reason: "category_not_decoded",
})),
});
}
});
});
+72
View File
@@ -0,0 +1,72 @@
const base = 0x100000000n;
/** Minimal inert Mach-O with one class instance method and protocol method. */
export const objcRecordBudgetMacho = (owner: "class" | "protocol"): Buffer => {
const bytes = Buffer.alloc(4096);
const u32 = (offset: number, value: number) =>
bytes.writeUInt32LE(value, offset);
const pointer = (offset: number, target: number) =>
bytes.writeBigUInt64LE(base + BigInt(target), offset);
const nullPointer = (offset: number) => bytes.writeBigUInt64LE(0n, offset);
u32(0, 0xfeedfacf);
u32(4, 0x0100000c);
u32(12, 2);
u32(16, 1);
u32(20, owner === "protocol" ? 232 : 152);
u32(32, 0x19);
u32(36, owner === "protocol" ? 232 : 152);
bytes.write("__DATA", 40);
pointer(56, 0);
bytes.writeBigUInt64LE(4096n, 64);
bytes.writeBigUInt64LE(4096n, 80);
u32(88, 7);
u32(92, 5);
u32(96, owner === "protocol" ? 2 : 1);
bytes.write("__objc_classlist", 104);
bytes.write("__DATA", 120);
pointer(136, 0x180);
bytes.writeBigUInt64LE(8n, 144);
u32(152, 0x180);
if (owner === "protocol") {
bytes.write("__objc_protolist", 184);
bytes.write("__DATA", 200);
pointer(216, 0x6c0);
bytes.writeBigUInt64LE(8n, 224);
u32(232, 0x6c0);
pointer(0x6c0, 0x740);
}
pointer(0x180, 0x200);
// class_t: zero isa/superclass; class_ro_t is root, named and has one method.
nullPointer(0x200);
nullPointer(0x208);
pointer(0x220, 0x300);
u32(0x300, 2);
u32(0x308, 8);
pointer(0x318, 0x900);
pointer(0x320, 0x500);
nullPointer(0x328);
nullPointer(0x330);
nullPointer(0x340);
bytes.write("FixtureClass\0", 0x900);
u32(0x500, 24);
u32(0x504, 1);
pointer(0x508, 0x920);
pointer(0x510, 0x940);
pointer(0x518, 0x700);
bytes.write("classMethod\0", 0x920);
bytes.write("v@:\0", 0x940);
// Protocol method lists use the same declaration boundary and budget.
pointer(0x6c0, 0x740);
pointer(0x748, 0x980);
pointer(0x758, 0x800);
u32(0x780, 72);
u32(0x800, 24);
u32(0x804, 1);
pointer(0x808, 0x9a0);
bytes.write("FixtureProtocol\0", 0x980);
bytes.write("protocolMethod\0", 0x9a0);
return bytes;
};