chore: update main for dependency security verification

This commit is contained in:
gensokyo-dev committed 2026-10-23 13:31:38 +00:00
commit 1cd471487b
16 files changed
+571 -312

No files matched your search

+11 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -892,7 +892,7 @@ const registerConfiguredAnalysisTools = (
): void => {
registerAndroidTools(
server,
new AndroidAnalysisService(android),
new AndroidAnalysisService(android, recordEvidence),
toolLogger,
recordEvidence,
withAdmittedAnalysis,
+1 -19
View File
@@ -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(
+19 -8
View File
@@ -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")),
},
+46 -1
View File
@@ -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",
+19 -1
View File
@@ -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) => {
+65 -72
View File
@@ -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",
+21 -2
View File
@@ -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({