mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
chore: update main for dependency security verification
This commit is contained in:
commit
1cd471487b
16 files changed
+571
-312
No files matched your search
@@ -8,7 +8,7 @@ import {
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
jadxAppSchema,
|
||||
jadxClassPageSchema,
|
||||
type JadxClassPage,
|
||||
jadxClassSchema,
|
||||
jadxLoadSchema,
|
||||
jadxMethodSchema,
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
|
||||
/** The adapter consumes tool payloads, never provider RPC details outside this layer. */
|
||||
export interface JadxToolPort {
|
||||
listClasses(input: {
|
||||
readonly query: string;
|
||||
readonly offset: number;
|
||||
readonly limit: number;
|
||||
}): Promise<JadxClassPage>;
|
||||
json(
|
||||
name: string,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
@@ -138,13 +143,11 @@ const searchClasses = async (
|
||||
const pageSize = 4096;
|
||||
let offset = 0;
|
||||
do {
|
||||
const page = jadxClassPageSchema.parse(
|
||||
await tools.json("list_classes", {
|
||||
query: request.input.query,
|
||||
offset,
|
||||
limit: pageSize,
|
||||
}),
|
||||
);
|
||||
const page = await tools.listClasses({
|
||||
query: request.input.query,
|
||||
offset,
|
||||
limit: pageSize,
|
||||
});
|
||||
if (
|
||||
page.total_classes !== loaded.class_count ||
|
||||
page.offset !== offset ||
|
||||
|
||||
+34
-22
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js";
|
||||
|
||||
@@ -61,14 +61,24 @@ export const jadxAppSchema = z.object({
|
||||
target_sdk: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
});
|
||||
export const jadxClassPageSchema = z.object({
|
||||
scope: z.literal("top_level"),
|
||||
total_classes: count,
|
||||
next_offset: count,
|
||||
offset: count,
|
||||
limit: count,
|
||||
items: z.array(z.string()),
|
||||
});
|
||||
const jadxClassPageSchema = z
|
||||
.object({
|
||||
scope: z.literal("top_level"),
|
||||
total_classes: count,
|
||||
next_offset: count,
|
||||
offset: count,
|
||||
limit: count,
|
||||
items: z.array(z.string()),
|
||||
})
|
||||
.catchall(jsonValueSchema);
|
||||
export type JadxClassPage = z.infer<typeof jadxClassPageSchema>;
|
||||
|
||||
/** Validate a decoded page without dropping observed JSON members such as __proto__. */
|
||||
export const parseJadxClassPage = (value: JsonValue): JadxClassPage => {
|
||||
jadxClassPageSchema.parse(value);
|
||||
return value as JadxClassPage;
|
||||
};
|
||||
|
||||
export const jadxClassSchema = z.object({
|
||||
full_name: z.string(),
|
||||
name: z.string(),
|
||||
@@ -131,28 +141,30 @@ export const jadxXrefsSchema = z.object({
|
||||
}),
|
||||
),
|
||||
});
|
||||
const toolResponse = z.object({
|
||||
content: z
|
||||
.array(z.object({ type: z.literal("text"), text: z.string() }))
|
||||
.length(1),
|
||||
isError: z.boolean().optional(),
|
||||
});
|
||||
const toolResponse = z
|
||||
.object({
|
||||
content: z.tuple([
|
||||
z
|
||||
.object({ type: z.literal("text"), text: z.string() })
|
||||
.catchall(jsonValueSchema),
|
||||
]),
|
||||
isError: z.boolean().exactOptional(),
|
||||
})
|
||||
.catchall(jsonValueSchema);
|
||||
|
||||
/** Parse the upstream text-only envelope, preserving errors at the producing boundary. */
|
||||
export const parseJadxEnvelope = (
|
||||
response: unknown,
|
||||
response: JsonValue,
|
||||
operation: AndroidOperation,
|
||||
): { text: string; failed: boolean } => {
|
||||
): z.infer<typeof toolResponse> => {
|
||||
const parsed = toolResponse.safeParse(response);
|
||||
if (!parsed.success || parsed.data.content[0] === undefined)
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
operation,
|
||||
"JADX returned an unsupported MCP content envelope",
|
||||
);
|
||||
return {
|
||||
text: parsed.data.content[0].text,
|
||||
failed: parsed.data.isError === true,
|
||||
};
|
||||
// The schema establishes the shape; its object copy would omit __proto__.
|
||||
return response as z.infer<typeof toolResponse>;
|
||||
};
|
||||
|
||||
/** Decode an upstream JSON text payload without trusting its contents. */
|
||||
|
||||
+83
-35
@@ -1,9 +1,7 @@
|
||||
import { throwIfJadxInterrupted } from "./JadxInterruption.js";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { createAnalysisExecution } from "../application/AnalysisProvider.js";
|
||||
import type { AndroidAnalysisExecution } from "../application/android/AndroidAnalysisPort.js";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import type { AndroidPartialObservation } from "../domain/android/androidPartialObservation.js";
|
||||
import {
|
||||
@@ -13,12 +11,17 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js";
|
||||
import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js";
|
||||
import { JadxMcpTransport, type JadxLauncher } from "./JadxMcpTransport.js";
|
||||
import {
|
||||
parseJadxClassPage,
|
||||
jadxLoadSchema,
|
||||
parseJadxFailure,
|
||||
jadxRuntimeSchema,
|
||||
@@ -32,6 +35,16 @@ import {
|
||||
JADX_LIMITATIONS,
|
||||
} from "./JadxRelease.js";
|
||||
|
||||
type JadxCall = {
|
||||
readonly operation: string;
|
||||
readonly input: Readonly<Record<string, JsonValue>>;
|
||||
readonly response: JsonValue;
|
||||
};
|
||||
type JadxCallObservation = {
|
||||
readonly complete: JadxCall;
|
||||
inline?: JsonValue;
|
||||
};
|
||||
|
||||
/** One owned engine connection; its wire protocol stays inside the Android adapter. */
|
||||
export class JadxSession {
|
||||
readonly transport: JadxMcpTransport;
|
||||
@@ -47,11 +60,7 @@ export class JadxSession {
|
||||
readonly bridgeHash: string;
|
||||
}
|
||||
| undefined;
|
||||
#observedCalls: {
|
||||
readonly operation: string;
|
||||
readonly input: Readonly<Record<string, JsonValue>>;
|
||||
readonly response: JsonValue;
|
||||
}[] = [];
|
||||
#observedCalls: JadxCallObservation[] = [];
|
||||
#serverName: string | null = null;
|
||||
#serverVersion: string | null = null;
|
||||
#engineVersion: string | null = null;
|
||||
@@ -73,7 +82,7 @@ export class JadxSession {
|
||||
readonly signal: AbortSignal;
|
||||
/** Selected whole-operation deadline; also bounds each upstream call. */
|
||||
readonly timeoutMs: number;
|
||||
}): Promise<AnalysisExecution> {
|
||||
}): Promise<AndroidAnalysisExecution> {
|
||||
const { request, target, snapshot, jarHash, signal, timeoutMs } = context;
|
||||
this.#partialContext = {
|
||||
operation: request.operation,
|
||||
@@ -147,21 +156,39 @@ export class JadxSession {
|
||||
Math.floor(runtime.max_heap_bytes / (1024 * 1024)) || null,
|
||||
} as const;
|
||||
const result = await analyzeJadxRequest(tools, request, loaded, engine);
|
||||
return createAnalysisExecution(result, JADX_PROVIDER_IDENTITY, {
|
||||
rawResult: {
|
||||
server,
|
||||
bridge_sha256: context.bridgeHash,
|
||||
jar_sha256: jarHash,
|
||||
loaded,
|
||||
calls: this.#observedCalls,
|
||||
},
|
||||
subject: target,
|
||||
limitations: JADX_LIMITATIONS,
|
||||
locations:
|
||||
request.operation === "inspect_android_package"
|
||||
? [{ kind: "artifact-path", path: "AndroidManifest.xml" }]
|
||||
: [],
|
||||
const raw = jsonObjectSchema.parse({
|
||||
server,
|
||||
bridge_sha256: context.bridgeHash,
|
||||
jar_sha256: jarHash,
|
||||
loaded,
|
||||
});
|
||||
const execution = createAnalysisExecution(
|
||||
result,
|
||||
JADX_PROVIDER_IDENTITY,
|
||||
{
|
||||
rawResult: {
|
||||
...raw,
|
||||
calls: this.#observedCalls.map(({ complete }) => complete),
|
||||
},
|
||||
subject: target,
|
||||
limitations: JADX_LIMITATIONS,
|
||||
locations:
|
||||
request.operation === "inspect_android_package"
|
||||
? [{ kind: "artifact-path", path: "AndroidManifest.xml" }]
|
||||
: [],
|
||||
},
|
||||
);
|
||||
return request.operation === "search_android_classes"
|
||||
? {
|
||||
...execution,
|
||||
searchRawSummary: {
|
||||
...raw,
|
||||
calls: this.#observedCalls.map(
|
||||
({ complete, inline }) => inline ?? complete,
|
||||
),
|
||||
},
|
||||
}
|
||||
: execution;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
}
|
||||
@@ -189,7 +216,7 @@ export class JadxSession {
|
||||
server_version: this.#serverVersion,
|
||||
engine_version: this.#engineVersion,
|
||||
},
|
||||
calls: this.#observedCalls,
|
||||
calls: this.#observedCalls.map(({ complete }) => complete),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,7 +243,7 @@ export class JadxSession {
|
||||
const call = async (
|
||||
name: string,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<string> => {
|
||||
) => {
|
||||
throwIfJadxInterrupted(signal);
|
||||
// Keep an explicit bound: omitting it falls back to the SDK's 60-second default.
|
||||
const response = await this.#client.callTool(
|
||||
@@ -225,10 +252,12 @@ export class JadxSession {
|
||||
);
|
||||
const observedResponse = jsonValueSchema.parse(response);
|
||||
const call = { operation: name, input, response: observedResponse };
|
||||
this.#observedCalls.push(call);
|
||||
const envelope = parseJadxEnvelope(response, request.operation);
|
||||
if (envelope.failed) {
|
||||
const failure = parseJadxFailure(envelope.text);
|
||||
const observation: JadxCallObservation = { complete: call };
|
||||
this.#observedCalls.push(observation);
|
||||
const envelope = parseJadxEnvelope(observedResponse, request.operation);
|
||||
const text = envelope.content[0].text;
|
||||
if (envelope.isError === true) {
|
||||
const failure = parseJadxFailure(text);
|
||||
if (failure !== undefined) {
|
||||
if (name === "load_apk" && failure.kind === "jvm-memory-failure")
|
||||
throw new AnalysisResourceConstraintError(
|
||||
@@ -266,18 +295,37 @@ export class JadxSession {
|
||||
throw new ProviderAdapterError("jadx", request.operation, {
|
||||
diagnostics: {
|
||||
upstream_operation: name,
|
||||
reason: envelope.text,
|
||||
reason: text,
|
||||
target_path: target.path,
|
||||
target_sha256: target.sha256,
|
||||
},
|
||||
});
|
||||
}
|
||||
return envelope.text;
|
||||
return { text, envelope, observation };
|
||||
};
|
||||
return {
|
||||
text: call,
|
||||
text: async (name, input) => (await call(name, input)).text,
|
||||
json: async (name, input) =>
|
||||
parseJadxJson(await call(name, input), request.operation),
|
||||
parseJadxJson((await call(name, input)).text, request.operation),
|
||||
listClasses: async (input) => {
|
||||
const reply = await call("list_classes", input);
|
||||
const page = parseJadxClassPage(
|
||||
parseJadxJson(reply.text, request.operation),
|
||||
);
|
||||
const { items, ...audit } = page;
|
||||
reply.observation.inline = {
|
||||
...reply.observation.complete,
|
||||
inventory_elided: true,
|
||||
response: {
|
||||
...reply.envelope,
|
||||
content: reply.envelope.content.map((entry) => ({
|
||||
...entry,
|
||||
text: JSON.stringify({ ...audit, items_omitted: items.length }),
|
||||
})),
|
||||
},
|
||||
};
|
||||
return page;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,18 @@ import type {
|
||||
AnalysisExecution,
|
||||
ExecutionOptions,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { AndroidRequest } from "../../domain/android/androidAnalysis.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { ProviderAvailability } from "../AnalysisProvider.js";
|
||||
|
||||
/** Provider-authored inline projection; complete raw observations stay in rawResult. */
|
||||
export interface AndroidAnalysisExecution extends AnalysisExecution {
|
||||
readonly searchRawSummary?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Static Android inspection boundary; implementation details belong to the provider. */
|
||||
export interface AndroidAnalysisPort {
|
||||
/** Inspect the selected JAR and Java runtime without opening an APK. */
|
||||
@@ -18,5 +24,5 @@ export interface AndroidAnalysisPort {
|
||||
target: BinaryTarget,
|
||||
request: AndroidRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
): Promise<Result<AndroidAnalysisExecution, AnalysisError>>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
|
||||
import { summarizeRetainedAndroidSearch } from "./AndroidSearchEvidenceSummary.js";
|
||||
import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
|
||||
@@ -17,7 +19,10 @@ import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
/** Shared CLI/MCP admission, execution and Evidence composition for Android. */
|
||||
export class AndroidAnalysisService {
|
||||
constructor(readonly provider: AndroidAnalysisPort) {}
|
||||
constructor(
|
||||
readonly provider: AndroidAnalysisPort,
|
||||
readonly recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
) {}
|
||||
|
||||
/** Inspect a caller-selected APK and retain producing-provider provenance inline. */
|
||||
async execute(
|
||||
@@ -44,7 +49,7 @@ export class AndroidAnalysisService {
|
||||
);
|
||||
if (!executed.ok) return executed;
|
||||
const execution = executed.value;
|
||||
return completeProviderEvidence(
|
||||
const completed = completeProviderEvidence(
|
||||
createEvidence(
|
||||
execution.subject === undefined
|
||||
? target.value
|
||||
@@ -63,5 +68,16 @@ export class AndroidAnalysisService {
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
if (
|
||||
!completed.ok ||
|
||||
operation !== "search_android_classes" ||
|
||||
execution.searchRawSummary === undefined
|
||||
)
|
||||
return completed;
|
||||
return summarizeRetainedAndroidSearch(
|
||||
completed.value,
|
||||
execution.searchRawSummary,
|
||||
this.recordEvidence,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,66 +4,6 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
|
||||
/**
|
||||
* Class-search pages duplicate matching names already present in normalized
|
||||
* results; an empty query also returns the complete top-level inventory.
|
||||
* Keep the selection and pagination audit inline and link the retained
|
||||
* record carrying every complete producer reply.
|
||||
*/
|
||||
const elideInventoryText = (text: string): string | undefined => {
|
||||
let page: JsonValue;
|
||||
try {
|
||||
page = JSON.parse(text) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof page !== "object" ||
|
||||
page === null ||
|
||||
Array.isArray(page) ||
|
||||
!Array.isArray(page.items)
|
||||
)
|
||||
return undefined;
|
||||
const { items, ...audited } = page;
|
||||
return JSON.stringify({ ...audited, items_omitted: items.length });
|
||||
};
|
||||
|
||||
/** Replace inventoried page bodies with their pagination audit. */
|
||||
const summarizeInventoryCall = (call: JsonValue): JsonValue => {
|
||||
if (
|
||||
typeof call !== "object" ||
|
||||
call === null ||
|
||||
Array.isArray(call) ||
|
||||
call.operation !== "list_classes"
|
||||
)
|
||||
return call;
|
||||
const response = call.response;
|
||||
if (
|
||||
typeof response !== "object" ||
|
||||
response === null ||
|
||||
Array.isArray(response) ||
|
||||
!Array.isArray(response.content)
|
||||
)
|
||||
return call;
|
||||
let elided = false;
|
||||
const content = response.content.map((entry: JsonValue) => {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
Array.isArray(entry) ||
|
||||
typeof entry.text !== "string"
|
||||
)
|
||||
return entry;
|
||||
const summarized = elideInventoryText(entry.text);
|
||||
if (summarized === undefined) return entry;
|
||||
elided = true;
|
||||
return { ...entry, text: summarized };
|
||||
});
|
||||
return elided
|
||||
? { ...call, inventory_elided: true, response: { ...response, content } }
|
||||
: call;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retain the complete search Evidence — full raw provider calls included —
|
||||
* in the session record, then deliver a bounded summary Evidence inline.
|
||||
@@ -72,24 +12,13 @@ const summarizeInventoryCall = (call: JsonValue): JsonValue => {
|
||||
*/
|
||||
export const summarizeRetainedAndroidSearch = (
|
||||
evidence: Evidence,
|
||||
summary: Readonly<Record<string, JsonValue>>,
|
||||
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
if (recordEvidence === undefined) return ok(evidence);
|
||||
const recorded = recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
const subject = evidence.subject;
|
||||
const raw = evidence.raw_result;
|
||||
const summarized: JsonValue =
|
||||
raw === null ||
|
||||
typeof raw !== "object" ||
|
||||
Array.isArray(raw) ||
|
||||
!Array.isArray(raw.calls)
|
||||
? raw
|
||||
: {
|
||||
...raw,
|
||||
complete_evidence_id: evidence.evidence_id,
|
||||
calls: raw.calls.map(summarizeInventoryCall),
|
||||
};
|
||||
return ok(
|
||||
createEvidence(
|
||||
subject === null
|
||||
@@ -107,7 +36,7 @@ export const summarizeRetainedAndroidSearch = (
|
||||
operation: evidence.operation,
|
||||
parameters: evidence.parameters,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: summarized,
|
||||
rawResult: { ...summary, complete_evidence_id: evidence.evidence_id },
|
||||
confidence: evidence.confidence,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
|
||||
@@ -892,7 +892,7 @@ const registerConfiguredAnalysisTools = (
|
||||
): void => {
|
||||
registerAndroidTools(
|
||||
server,
|
||||
new AndroidAnalysisService(android),
|
||||
new AndroidAnalysisService(android, recordEvidence),
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EvidenceWriter } from "../application/investigation/InvestigationR
|
||||
import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js";
|
||||
import { inspectAndroidPackage } from "../application/android/AndroidPackageInspection.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import { summarizeRetainedAndroidSearch } from "../application/android/AndroidSearchEvidenceSummary.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
@@ -29,23 +28,6 @@ export const registerAndroidTools = (
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
// Focused class searches keep complete raw inventory in the retained
|
||||
// record and deliver a bounded summary inline; the generic handler then
|
||||
// records and delivers the summary as the search contract's Evidence.
|
||||
const searchService: ContractToolService<"search_android_classes"> = {
|
||||
execute: async (operation, input, options) => {
|
||||
const executed = await service.execute(operation, input, options);
|
||||
if (!executed.ok) return executed;
|
||||
return summarizeRetainedAndroidSearch(executed.value, recordEvidence);
|
||||
},
|
||||
};
|
||||
const searchHandler = createContractToolHandler(
|
||||
server,
|
||||
searchService,
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
const packageContract = toolContract("inspect_android_package");
|
||||
const packageService: ContractToolService<"inspect_android_package"> = {
|
||||
execute: (_operation, input, options) =>
|
||||
@@ -72,7 +54,7 @@ export const registerAndroidTools = (
|
||||
server.registerTool(
|
||||
searchContract.name,
|
||||
toolRegistrationOptions(searchContract),
|
||||
searchHandler(searchContract),
|
||||
handler(searchContract),
|
||||
);
|
||||
const classContract = toolContract("inspect_android_class");
|
||||
server.registerTool(
|
||||
|
||||
@@ -50,12 +50,12 @@ it("pages filtered matches independently of the examined inventory and preserves
|
||||
names[4096] = "fixture.TargetDuplicate";
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
const tools: JadxToolPort = {
|
||||
json: (name, input) => {
|
||||
expect(name).toBe("list_classes");
|
||||
json: () => Promise.reject(new Error("unexpected JSON call")),
|
||||
listClasses: (input) => {
|
||||
calls.push({ ...input });
|
||||
const offset = Number(input.offset);
|
||||
return Promise.resolve({
|
||||
scope: "top_level",
|
||||
scope: "top_level" as const,
|
||||
total_classes: 10000,
|
||||
next_offset: offset === 0 ? 8191 : 10000,
|
||||
offset,
|
||||
@@ -94,7 +94,6 @@ it("pages filtered matches independently of the examined inventory and preserves
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "inventory scope", query: "Target", page: { scope: "all_dex" } },
|
||||
{ name: "inventory total", query: "Target", page: { total_classes: 9 } },
|
||||
{ name: "out-of-range cursor", query: "Target", page: { next_offset: 11 } },
|
||||
{
|
||||
@@ -116,9 +115,10 @@ it.each([
|
||||
"refuses changed $name instead of reporting complete search coverage",
|
||||
async ({ query, page }) => {
|
||||
const tools: JadxToolPort = {
|
||||
json: () =>
|
||||
json: () => Promise.reject(new Error("unexpected JSON call")),
|
||||
listClasses: () =>
|
||||
Promise.resolve({
|
||||
scope: "top_level",
|
||||
scope: "top_level" as const,
|
||||
total_classes: 10,
|
||||
next_offset: 10,
|
||||
offset: 0,
|
||||
@@ -151,9 +151,10 @@ it.each([
|
||||
|
||||
it("accepts an examined empty inventory without requiring cursor progress", async () => {
|
||||
const tools: JadxToolPort = {
|
||||
json: () =>
|
||||
json: () => Promise.reject(new Error("unexpected JSON call")),
|
||||
listClasses: () =>
|
||||
Promise.resolve({
|
||||
scope: "top_level",
|
||||
scope: "top_level" as const,
|
||||
total_classes: 0,
|
||||
offset: 0,
|
||||
next_offset: 0,
|
||||
@@ -225,6 +226,8 @@ it("requires explicit direct invoke coverage state in result evidence", () => {
|
||||
|
||||
it("publishes exact DEX prototypes instead of discarding bridge metadata", async () => {
|
||||
const tools: JadxToolPort = {
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
json: () => Promise.resolve(summary),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
};
|
||||
@@ -251,6 +254,8 @@ it("publishes exact DEX prototypes instead of discarding bridge metadata", async
|
||||
it("traces only the selected overloaded method and checks the producer identity", async () => {
|
||||
const calls: Record<string, unknown>[] = [];
|
||||
const tools: JadxToolPort = {
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
json: (name, input) => {
|
||||
calls.push({ name, ...input });
|
||||
if (name === "get_class_summary") return Promise.resolve(summary);
|
||||
@@ -334,6 +339,8 @@ it("rejects an ambiguous method and a producer response for a different overload
|
||||
items: [],
|
||||
};
|
||||
const tools: JadxToolPort = {
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
json: (name) =>
|
||||
Promise.resolve(name === "get_class_summary" ? summary : xrefs),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
@@ -381,6 +388,8 @@ const invokeSite = {
|
||||
const traceWithReference = (fields: Record<string, unknown>) =>
|
||||
analyzeJadxRequest(
|
||||
{
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
json: (name) =>
|
||||
Promise.resolve(
|
||||
name === "get_class_summary"
|
||||
@@ -482,6 +491,8 @@ it.each([
|
||||
await expect(
|
||||
analyzeJadxRequest(
|
||||
{
|
||||
listClasses: () =>
|
||||
Promise.reject(new Error("unexpected class inventory call")),
|
||||
json: () => Promise.resolve(invalidSummary),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
parseJadxEnvelope,
|
||||
parseJadxClassPage,
|
||||
parseJadxJson,
|
||||
normalizeJadxText,
|
||||
parseJadxFailure,
|
||||
@@ -36,7 +37,10 @@ it("validates provider text envelopes and rejects unsupported content rather tha
|
||||
{ content: [{ type: "text", text: "reported error" }], isError: true },
|
||||
"inspect_android_package",
|
||||
),
|
||||
).toEqual({ text: "reported error", failed: true });
|
||||
).toEqual({
|
||||
content: [{ type: "text", text: "reported error" }],
|
||||
isError: true,
|
||||
});
|
||||
for (const response of [
|
||||
{ content: [] },
|
||||
{ content: [{ type: "image", data: "AA==", mimeType: "image/png" }] },
|
||||
@@ -109,3 +113,44 @@ it("rejects invalid APK selectors and accepts an explicit zero overload without
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves JSON page and envelope annotations while rejecting unsupported inventory scope", () => {
|
||||
const page = {
|
||||
scope: "top_level",
|
||||
total_classes: 1,
|
||||
offset: 0,
|
||||
next_offset: 1,
|
||||
limit: 4096,
|
||||
items: ["fixture.Target"],
|
||||
audit: { source: "雪", missing: null },
|
||||
};
|
||||
Object.defineProperty(page, "__proto__", {
|
||||
value: { observed: "page" },
|
||||
enumerable: true,
|
||||
});
|
||||
expect(parseJadxClassPage(page)).toEqual(page);
|
||||
expect(Object.hasOwn(parseJadxClassPage(page), "__proto__")).toBe(true);
|
||||
expect(() => parseJadxClassPage({ ...page, scope: "all_dex" })).toThrow();
|
||||
const response = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(page),
|
||||
annotations: { audience: ["assistant"] },
|
||||
},
|
||||
],
|
||||
_meta: { page: 1 },
|
||||
};
|
||||
Object.defineProperty(response, "__proto__", {
|
||||
value: { observed: "envelope" },
|
||||
enumerable: true,
|
||||
});
|
||||
Object.defineProperty(response.content[0], "__proto__", {
|
||||
value: { observed: "content" },
|
||||
enumerable: true,
|
||||
});
|
||||
const parsed = parseJadxEnvelope(response, "search_android_classes");
|
||||
expect(parsed).toEqual(response);
|
||||
expect(Object.hasOwn(parsed, "__proto__")).toBe(true);
|
||||
expect(Object.hasOwn(parsed.content[0], "__proto__")).toBe(true);
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Dir, Dirent } from "node:fs";
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -7,30 +6,42 @@ import { expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { importReferenceSource } from "../../support/referenceSourceResourceScope.js";
|
||||
|
||||
const { opendirMock } = vi.hoisted(() => ({ opendirMock: vi.fn() }));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:fs/promises")>()),
|
||||
opendir: opendirMock,
|
||||
const injectedReadFailure = vi.hoisted(() => ({
|
||||
path: undefined as string | undefined,
|
||||
error: undefined as Error | undefined,
|
||||
}));
|
||||
|
||||
const directoryHandle = (items: readonly (string | Error | null)[]): Dir => {
|
||||
let index = 0;
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
async read() {
|
||||
const item = items[index++];
|
||||
if (item instanceof Error) throw item;
|
||||
return item === null || item === undefined
|
||||
? null
|
||||
: ({ name: item } as Dirent);
|
||||
...actual,
|
||||
opendir: async (...args: Parameters<typeof actual.opendir>) => {
|
||||
const handle = await actual.opendir(...args);
|
||||
const [path] = args;
|
||||
const { path: failingPath, error } = injectedReadFailure;
|
||||
if (path !== failingPath || error === undefined) return handle;
|
||||
|
||||
const read = handle.read.bind(handle);
|
||||
let failureInjected = false;
|
||||
Object.defineProperty(handle, "read", {
|
||||
configurable: true,
|
||||
value: async () => {
|
||||
const entry = await read();
|
||||
if (entry === null && !failureInjected) {
|
||||
failureInjected = true;
|
||||
throw error;
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
},
|
||||
async close() {},
|
||||
} as unknown as Dir;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"traverses directories lstat-observed before their parent enumeration failed",
|
||||
async () => {
|
||||
async ({ onTestFinished }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-queued-source-directories-",
|
||||
);
|
||||
@@ -46,18 +57,11 @@ it.skipIf(process.platform === "win32")(
|
||||
new Error("injected directory read failure"),
|
||||
{ code: "EIO" },
|
||||
);
|
||||
opendirMock.mockImplementation(async (path: string) => {
|
||||
if (path === root) return directoryHandle(["container", null]);
|
||||
if (path === container)
|
||||
return directoryHandle([
|
||||
"visible.txt",
|
||||
"hidden",
|
||||
"alias-file.txt",
|
||||
"alias-directory",
|
||||
readFailure,
|
||||
]);
|
||||
if (path === hidden) return directoryHandle(["target.txt", null]);
|
||||
throw new Error(`Unexpected directory traversal: ${path}`);
|
||||
injectedReadFailure.path = container;
|
||||
injectedReadFailure.error = readFailure;
|
||||
onTestFinished(() => {
|
||||
injectedReadFailure.path = undefined;
|
||||
injectedReadFailure.error = undefined;
|
||||
});
|
||||
|
||||
const imported = await importReferenceSource({
|
||||
@@ -73,6 +77,7 @@ it.skipIf(process.platform === "win32")(
|
||||
path: "container",
|
||||
kind: "directory",
|
||||
tree_state: "unreadable",
|
||||
limitations: [expect.stringContaining(readFailure.message)],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "container/hidden",
|
||||
|
||||
@@ -429,7 +429,7 @@ it.each([
|
||||
]);
|
||||
});
|
||||
it.each([
|
||||
["parameter", ".u64 output", ".u64 .ptr .global output"],
|
||||
["parameter", ".u64 output", ".u64 .vendor_specific output"],
|
||||
["shared", "scratch[32]", "scratch[]"],
|
||||
] as const)(
|
||||
"marks unknown PTX %s declarations partial while retaining known facts",
|
||||
@@ -459,6 +459,24 @@ it.each([
|
||||
}
|
||||
},
|
||||
);
|
||||
it("keeps supported PTX pointer qualifiers complete through the provider", async () => {
|
||||
const bytes = Buffer.from(
|
||||
ptxFixture.replace(".u64 output", ".u64 .ptr .global output"),
|
||||
);
|
||||
const result = await inspect(bytes);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.objects[0]!.inspection).toBe("complete");
|
||||
expect(result.value.coverage.payloads).toBe("complete");
|
||||
expect(result.value.ptx_artifacts[0]!.text).toBe(bytes.toString());
|
||||
expect(result.value.kernels[0]).toMatchObject({
|
||||
parameter_count: 2,
|
||||
parameter_bytes: 12,
|
||||
parameters: [
|
||||
{ name: "output", offset: 0, size: 8 },
|
||||
{ name: "value", offset: 8, size: 4 },
|
||||
],
|
||||
});
|
||||
});
|
||||
it.each(["whitespace", "comment"] as const)(
|
||||
"recognizes standalone PTX after more than 64 KiB of leading %s",
|
||||
async (kind) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { chmod, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
@@ -16,7 +16,7 @@ import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
it.skipIf(process.platform === "win32").each(["kill", "detach"])(
|
||||
"retains a valid GDB session through disposition refusal and explicit %s recovery",
|
||||
async (disposition) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-gdb-disposition-"));
|
||||
const directory = await createTestTempDirectory("rea-gdb-disposition-");
|
||||
const provider = join(directory, "fake-gdb.mjs");
|
||||
await writeFile(
|
||||
provider,
|
||||
@@ -33,6 +33,10 @@ createInterface({ input: process.stdin }).on('line', line => {
|
||||
process.stdout.write(token + '^running\\n*stopped,reason="breakpoint-hit",thread-id="1"\\n(gdb)\\n');
|
||||
return;
|
||||
}
|
||||
if (command === '-gdb-show mi-async') {
|
||||
process.stdout.write(token + '^done,value="off"\\n(gdb)\\n');
|
||||
return;
|
||||
}
|
||||
const disposition = command === '-interpreter-exec console "kill"' || command === '-interpreter-exec console "detach"';
|
||||
if (disposition) live = false;
|
||||
const result = command === '-list-thread-groups --recurse 1'
|
||||
@@ -50,10 +54,12 @@ createInterface({ input: process.stdin }).on('line', line => {
|
||||
platform: "linux",
|
||||
environment: { REA_GDB_COMMAND: provider },
|
||||
});
|
||||
onTestFinished(() => manager.closeAll());
|
||||
const server = new McpServer({
|
||||
name: "gdb-disposition-test",
|
||||
version: "1",
|
||||
});
|
||||
onTestFinished(() => server.close());
|
||||
registerGdbTools(
|
||||
server,
|
||||
manager,
|
||||
@@ -61,80 +67,67 @@ createInterface({ input: process.stdin }).on('line', line => {
|
||||
createToolResultDelivery(undefined),
|
||||
);
|
||||
const client = new Client({ name: "gdb-disposition-test", version: "1" });
|
||||
onTestFinished(() => client.close());
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const started = await client.callTool({
|
||||
name: "start_gdb_session",
|
||||
arguments: {},
|
||||
});
|
||||
sessionId = toolContract("start_gdb_session").outputSchema.parse(
|
||||
started.structuredContent,
|
||||
).session_id;
|
||||
const running = await client.callTool({
|
||||
name: "gdb_console",
|
||||
arguments: { session_id: sessionId, command: "run" },
|
||||
});
|
||||
expect(running.isError).not.toBe(true);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const refusal = await client.callTool({
|
||||
name: "close_gdb_session",
|
||||
arguments: { session_id: sessionId },
|
||||
});
|
||||
expect(refusal.isError).toBe(true);
|
||||
expect(refusal.structuredContent).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
category: "unavailable",
|
||||
retryable: true,
|
||||
remediation: { action: expect.stringContaining("gdb_console") },
|
||||
details: {
|
||||
provider_id: "gnu.gdb",
|
||||
operation: "close_gdb_session",
|
||||
reason: expect.stringContaining("inferior"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(refusal.structuredContent)).not.toContain(
|
||||
"invalid_value",
|
||||
);
|
||||
expect(manager.status(sessionId)?.state).toBe("ready");
|
||||
}
|
||||
const disposed = await client.callTool({
|
||||
name: "gdb_console",
|
||||
arguments: { session_id: sessionId, command: disposition },
|
||||
});
|
||||
expect(disposed.isError).not.toBe(true);
|
||||
const closed = await client.callTool({
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const started = await client.callTool({
|
||||
name: "start_gdb_session",
|
||||
arguments: {},
|
||||
});
|
||||
const sessionId = toolContract("start_gdb_session").outputSchema.parse(
|
||||
started.structuredContent,
|
||||
).session_id;
|
||||
const running = await client.callTool({
|
||||
name: "gdb_console",
|
||||
arguments: { session_id: sessionId, command: "run" },
|
||||
});
|
||||
expect(running.isError).not.toBe(true);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const refusal = await client.callTool({
|
||||
name: "close_gdb_session",
|
||||
arguments: { session_id: sessionId },
|
||||
});
|
||||
expect(closed.isError).not.toBe(true);
|
||||
expect(closed.structuredContent).toMatchObject({
|
||||
target_state: "none_observed",
|
||||
expect(refusal.isError).toBe(true);
|
||||
expect(refusal.structuredContent).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
category: "unavailable",
|
||||
retryable: true,
|
||||
remediation: { action: expect.stringContaining("gdb_console") },
|
||||
details: {
|
||||
provider_id: "gnu.gdb",
|
||||
operation: "close_gdb_session",
|
||||
reason: expect.stringContaining("inferior"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(manager.status(sessionId)).toBeUndefined();
|
||||
const unknown = await client.callTool({
|
||||
name: "close_gdb_session",
|
||||
arguments: { session_id: randomUUID() },
|
||||
});
|
||||
expect(unknown.structuredContent).toMatchObject({
|
||||
error: { code: "invalid_request", category: "invalid_input" },
|
||||
});
|
||||
} finally {
|
||||
if (sessionId !== undefined && manager.status(sessionId) !== undefined) {
|
||||
const disposed = await manager.execute(sessionId, "kill");
|
||||
if (!disposed.ok) throw disposed.error;
|
||||
const closed = await manager.close(sessionId);
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
await client.close();
|
||||
await server.close();
|
||||
await manager.closeAll();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
expect(JSON.stringify(refusal.structuredContent)).not.toContain(
|
||||
"invalid_value",
|
||||
);
|
||||
expect(manager.status(sessionId)?.state).toBe("ready");
|
||||
}
|
||||
const disposed = await client.callTool({
|
||||
name: "gdb_console",
|
||||
arguments: { session_id: sessionId, command: disposition },
|
||||
});
|
||||
expect(disposed.isError).not.toBe(true);
|
||||
const closed = await client.callTool({
|
||||
name: "close_gdb_session",
|
||||
arguments: { session_id: sessionId },
|
||||
});
|
||||
expect(closed.isError).not.toBe(true);
|
||||
expect(closed.structuredContent).toMatchObject({
|
||||
target_state: "none_observed",
|
||||
});
|
||||
expect(manager.status(sessionId)).toBeUndefined();
|
||||
const unknown = await client.callTool({
|
||||
name: "close_gdb_session",
|
||||
arguments: { session_id: randomUUID() },
|
||||
});
|
||||
expect(unknown.structuredContent).toMatchObject({
|
||||
error: { code: "invalid_request", category: "invalid_input" },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it as test, onTestFinished } from "vitest";
|
||||
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
|
||||
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
|
||||
import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js";
|
||||
import { err } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
@@ -11,29 +14,54 @@ import {
|
||||
|
||||
const INVENTORY_CLASS_COUNT = 7096;
|
||||
|
||||
/** Count inventoried names across raw call records: MCP pages ride in text content. */
|
||||
const rawInventoryNames = (raw: unknown): number => {
|
||||
const calls =
|
||||
(
|
||||
raw as {
|
||||
calls?: {
|
||||
operation: string;
|
||||
response: { content?: { text?: string }[] };
|
||||
}[];
|
||||
}
|
||||
).calls ?? [];
|
||||
return calls
|
||||
.flatMap((call) => call.response.content ?? [])
|
||||
.map((entry) =>
|
||||
entry.text === undefined ? {} : (JSON.parse(entry.text) as unknown),
|
||||
)
|
||||
.filter((page) => Array.isArray((page as { items?: unknown[] }).items))
|
||||
.flatMap((page) => (page as { items: unknown[] }).items).length;
|
||||
interface RawJadxCall {
|
||||
readonly operation: string;
|
||||
readonly input?: { readonly offset?: number; readonly query?: string };
|
||||
readonly response: {
|
||||
readonly _meta?: Readonly<Record<string, unknown>>;
|
||||
readonly content?: readonly {
|
||||
readonly text?: string;
|
||||
readonly annotations?: unknown;
|
||||
readonly _meta?: Readonly<Record<string, unknown>>;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
const callsOf = (raw: unknown): readonly RawJadxCall[] =>
|
||||
(raw as { calls?: readonly RawJadxCall[] }).calls ?? [];
|
||||
|
||||
const listClassCalls = (raw: unknown): readonly RawJadxCall[] =>
|
||||
callsOf(raw).filter(({ operation }) => operation === "list_classes");
|
||||
|
||||
const responsePage = (call: RawJadxCall): Record<string, unknown> => {
|
||||
const text = call.response.content?.[0]?.text;
|
||||
if (text === undefined) throw new Error("JADX response omitted page text");
|
||||
const page: unknown = JSON.parse(text);
|
||||
if (page === null || typeof page !== "object" || Array.isArray(page))
|
||||
throw new Error("JADX page text was not an object");
|
||||
return page as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const callMetadata = (call: RawJadxCall) => ({
|
||||
offset: call.input?.offset,
|
||||
envelope: call.response._meta,
|
||||
content: call.response.content?.[0]?._meta,
|
||||
annotations: call.response.content?.[0]?.annotations,
|
||||
fixture: responsePage(call).fixture_metadata,
|
||||
});
|
||||
|
||||
/** Count names in complete source pages using the producer's raw JSON. */
|
||||
const rawInventoryNames = (raw: unknown): number =>
|
||||
listClassCalls(raw).reduce((sum, call) => {
|
||||
const items = responsePage(call).items;
|
||||
if (!Array.isArray(items))
|
||||
throw new Error("Complete page omitted class names");
|
||||
return sum + items.length;
|
||||
}, 0);
|
||||
|
||||
test("delivers bounded search_android_classes Evidence while retaining the complete inventory", async () => {
|
||||
const { provider, apk, launches } = await createJadxProtocolFixture(
|
||||
"normal",
|
||||
"search-audit-metadata",
|
||||
undefined,
|
||||
{ classCount: INVENTORY_CLASS_COUNT - 1 },
|
||||
);
|
||||
@@ -111,6 +139,44 @@ test("delivers bounded search_android_classes Evidence while retaining the compl
|
||||
expect(rawInventoryNames(inventorySource?.raw_result)).toBe(
|
||||
INVENTORY_CLASS_COUNT,
|
||||
);
|
||||
const summaryPages = listClassCalls(inventory.raw_result);
|
||||
const completePages = listClassCalls(inventorySource?.raw_result);
|
||||
expect(summaryPages).toHaveLength(2);
|
||||
expect(completePages).toHaveLength(2);
|
||||
expect(
|
||||
summaryPages.map((call) => {
|
||||
const page = responsePage(call);
|
||||
return {
|
||||
offset: page.offset,
|
||||
next_offset: page.next_offset,
|
||||
total_classes: page.total_classes,
|
||||
limit: page.limit,
|
||||
items_omitted: page.items_omitted,
|
||||
};
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
offset: 0,
|
||||
next_offset: 4096,
|
||||
total_classes: INVENTORY_CLASS_COUNT,
|
||||
limit: 4096,
|
||||
items_omitted: 4096,
|
||||
},
|
||||
{
|
||||
offset: 4096,
|
||||
next_offset: INVENTORY_CLASS_COUNT,
|
||||
total_classes: INVENTORY_CLASS_COUNT,
|
||||
limit: 4096,
|
||||
items_omitted: INVENTORY_CLASS_COUNT - 4096,
|
||||
},
|
||||
]);
|
||||
expect(summaryPages.map(callMetadata)).toEqual(
|
||||
completePages.map(callMetadata),
|
||||
);
|
||||
expect(completePages.map(responsePage).map((page) => page.items)).toEqual([
|
||||
expect.arrayContaining(["fixture.Target", "fixture.Class0"]),
|
||||
expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]),
|
||||
]);
|
||||
|
||||
const empty = await search("fixture.Missing");
|
||||
expect(
|
||||
@@ -122,28 +188,125 @@ test("delivers bounded search_android_classes Evidence while retaining the compl
|
||||
coverage: "complete",
|
||||
});
|
||||
expect(JSON.stringify(empty.raw_result)).not.toContain("fixture.Class");
|
||||
expect(listClassCalls(empty.raw_result).map(responsePage)).toMatchObject([
|
||||
{
|
||||
offset: 0,
|
||||
next_offset: INVENTORY_CLASS_COUNT,
|
||||
total_classes: INVENTORY_CLASS_COUNT,
|
||||
limit: 4096,
|
||||
items_omitted: 0,
|
||||
fixture_metadata: {
|
||||
page_offset: 0,
|
||||
retained: "unknown page field",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
});
|
||||
|
||||
test("keeps complete matching producer rows inline without a retaining session", async () => {
|
||||
test("keeps the complete paged inventory inline without a retaining session", async () => {
|
||||
const { service, apk, launches, provider } = await createJadxProtocolFixture(
|
||||
"normal",
|
||||
"search-audit-metadata",
|
||||
undefined,
|
||||
{ classCount: INVENTORY_CLASS_COUNT - 1 },
|
||||
);
|
||||
const result = await service.execute("search_android_classes", {
|
||||
path: apk,
|
||||
query: "Target",
|
||||
query: "",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
// Complete CLI Evidence keeps every matching row, without unrelated names.
|
||||
expect(rawInventoryNames(result.value.raw_result)).toBe(1);
|
||||
// No retaining writer means the CLI receives the unmodified source pages.
|
||||
expect(rawInventoryNames(result.value.raw_result)).toBe(
|
||||
INVENTORY_CLASS_COUNT,
|
||||
);
|
||||
const fullPages = listClassCalls(result.value.raw_result);
|
||||
expect(fullPages).toHaveLength(2);
|
||||
expect(fullPages.map(responsePage)).toMatchObject([
|
||||
{
|
||||
offset: 0,
|
||||
next_offset: 4096,
|
||||
total_classes: INVENTORY_CLASS_COUNT,
|
||||
limit: 4096,
|
||||
items: expect.arrayContaining(["fixture.Target", "fixture.Class0"]),
|
||||
},
|
||||
{
|
||||
offset: 4096,
|
||||
next_offset: INVENTORY_CLASS_COUNT,
|
||||
total_classes: INVENTORY_CLASS_COUNT,
|
||||
limit: 4096,
|
||||
items: expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]),
|
||||
},
|
||||
]);
|
||||
expect(responsePage(fullPages[0]!)).toMatchObject({
|
||||
fixture_metadata: {
|
||||
page_offset: 0,
|
||||
retained: "unknown page field",
|
||||
},
|
||||
});
|
||||
for (const [index, call] of fullPages.entries()) {
|
||||
expect(call.response._meta).toEqual({
|
||||
fixture_envelope_offset: index * 4096,
|
||||
});
|
||||
expect(call.response.content?.[0]?._meta).toEqual({
|
||||
fixture_content_offset: index * 4096,
|
||||
});
|
||||
expect(call.response.content?.[0]?.annotations).toEqual({
|
||||
audience: ["user"],
|
||||
priority: 0.5,
|
||||
});
|
||||
}
|
||||
expect(JSON.stringify(result.value.raw_result)).not.toContain(
|
||||
"fixture.Class",
|
||||
"inventory_elided",
|
||||
);
|
||||
expect(JSON.stringify(result.value.raw_result)).not.toContain(
|
||||
"items_omitted",
|
||||
);
|
||||
await provider.close();
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
});
|
||||
|
||||
test("withholds inline summary when retaining complete Evidence fails", async () => {
|
||||
const { provider, apk, launches } = await createJadxProtocolFixture(
|
||||
"search-audit-metadata",
|
||||
undefined,
|
||||
{ classCount: INVENTORY_CLASS_COUNT - 1 },
|
||||
);
|
||||
const writeFailure = new EvidenceIntegrityError(
|
||||
"The complete Android search Evidence could not be retained",
|
||||
);
|
||||
const retained: Evidence[] = [];
|
||||
const service = new AndroidAnalysisService(provider, (evidence) => {
|
||||
retained.push(evidence);
|
||||
return err(writeFailure);
|
||||
});
|
||||
|
||||
const result = await service.execute("search_android_classes", {
|
||||
path: apk,
|
||||
query: "",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, error: writeFailure });
|
||||
expect(retained).toHaveLength(1);
|
||||
expect(retained[0]?.operation).toBe("search_android_classes");
|
||||
expect(
|
||||
androidResultSchemas.search_android_classes.parse(
|
||||
retained[0]?.normalized_result,
|
||||
),
|
||||
).toMatchObject({
|
||||
total_classes: INVENTORY_CLASS_COUNT,
|
||||
matches: expect.arrayContaining(["fixture.Target", "fixture.Class7094"]),
|
||||
coverage: "complete",
|
||||
});
|
||||
expect(rawInventoryNames(retained[0]?.raw_result)).toBe(
|
||||
INVENTORY_CLASS_COUNT,
|
||||
);
|
||||
expect(JSON.stringify(retained[0]?.raw_result)).not.toContain(
|
||||
"items_omitted",
|
||||
);
|
||||
|
||||
await provider.close();
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { expect, it } from "vitest";
|
||||
|
||||
import { AndroidAnalysisService } from "../../../../src/application/android/AndroidAnalysisService.js";
|
||||
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
|
||||
import { ok } from "../../../../src/domain/result.js";
|
||||
import { parseEvidence } from "../../../../src/domain/evidence.js";
|
||||
import type { AnalysisExecution } from "../../../../src/application/AnalysisProvider.js";
|
||||
import { createJadxProtocolFixture } from "../../../fixtures/android/jadx.js";
|
||||
@@ -32,18 +33,25 @@ it.each([
|
||||
if (!control.ok) throw control.error;
|
||||
const controller = new AbortController();
|
||||
const observations: AnalysisExecution[] = [];
|
||||
const service = new AndroidAnalysisService({
|
||||
inspectAvailability: fixture.provider.inspectAvailability.bind(
|
||||
fixture.provider,
|
||||
),
|
||||
close: fixture.provider.close.bind(fixture.provider),
|
||||
execute: async (...args) => {
|
||||
const completed = await fixture.provider.execute(...args);
|
||||
if (completed.ok) observations.push(completed.value);
|
||||
controller.abort();
|
||||
return completed;
|
||||
let records = 0;
|
||||
const service = new AndroidAnalysisService(
|
||||
{
|
||||
inspectAvailability: fixture.provider.inspectAvailability.bind(
|
||||
fixture.provider,
|
||||
),
|
||||
close: fixture.provider.close.bind(fixture.provider),
|
||||
execute: async (...args) => {
|
||||
const completed = await fixture.provider.execute(...args);
|
||||
if (completed.ok) observations.push(completed.value);
|
||||
controller.abort();
|
||||
return completed;
|
||||
},
|
||||
},
|
||||
});
|
||||
() => {
|
||||
records += 1;
|
||||
return ok("added" as const);
|
||||
},
|
||||
);
|
||||
const result = await service.execute(operation, input, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -66,6 +74,7 @@ it.each([
|
||||
normalized_result: control.value.normalized_result,
|
||||
});
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(records).toBe(0);
|
||||
expect(partial.raw_result).toEqual(observations[0]?.rawResult);
|
||||
expect(control.value.confidence).toBe(
|
||||
operation === "inspect_android_method" ? "derived" : "observed",
|
||||
|
||||
Vendored
+21
-2
@@ -127,14 +127,33 @@ register("list_classes", ({ offset, limit, query = "" }) => {
|
||||
const name = classes[nextOffset++];
|
||||
if (name.includes(query)) matching.push(name);
|
||||
}
|
||||
return json({
|
||||
const page = {
|
||||
scope: "top_level",
|
||||
total_classes: classes.length,
|
||||
next_offset: nextOffset,
|
||||
offset,
|
||||
limit,
|
||||
items: mode === "empty-page" ? [] : matching,
|
||||
});
|
||||
...(mode === "search-audit-metadata"
|
||||
? {
|
||||
fixture_metadata: {
|
||||
page_offset: offset,
|
||||
retained: "unknown page field",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const response = json(page);
|
||||
if (mode !== "search-audit-metadata") return response;
|
||||
return {
|
||||
...response,
|
||||
_meta: { fixture_envelope_offset: offset },
|
||||
content: response.content.map((entry) => ({
|
||||
...entry,
|
||||
annotations: { audience: ["user"], priority: 0.5 },
|
||||
_meta: { fixture_content_offset: offset },
|
||||
})),
|
||||
};
|
||||
});
|
||||
register("get_class_summary", () =>
|
||||
json({
|
||||
|
||||
Reference in new issue
Block a user