feat(android): add opt-in package summaries and retained manifest views

Add explicit package summary delivery and exact retained manifest pages while preserving complete MCP defaults, one-shot CLI output, provider requests, source identity and session lifetime. Return distinct linked Evidence and preserve partial/null facts; reject unavailable retention before engine work.

Validation: 27 relevant SDK/domain/Android boundary checks, focused test-only correction rerun, full supported compiler/lint and docs checks, real NewPipe SDK pairs, authentic old Evidence continuation, four actual Codex trials with failures/regressions retained, and all 39 exact-head CI checks. Package replies shrink 119,527 to 8,659 bytes; no model-token or time gain is established.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Ysopwnable 2026-10-13 10:21:53 +08:00 committed by GitHub
parent ca2d15b3ee
commit f254f5ac2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 777 additions and 11 deletions

View File

@ -9,6 +9,27 @@ This family is present on repository main and in npm 4.1.0. Check the
[released package boundary](installation.md#released-package-and-main) and the
connected server's tool list before selecting it.
## Optional package delivery
The MCP `inspect_android_package` request keeps complete output when `detail` is
omitted or `"complete"`. Select `detail: "summary"` explicitly to receive all
package, SDK and permission declarations, engine identity and class/resource
counts without decoded manifest text or duplicate raw provider replies. A
retaining session keeps the complete original Evidence and returns a distinct
linked `inspect_analysis_view` summary. This changes delivery, not JADX analysis.
Use the returned `normalized_result.parent_evidence_id` with
`inspect_analysis_view`, source kind `retained-evidence`, and
`view: {kind: "android", facet: "manifest", offset: 0, limit: 4096}` to read the
exact decoded manifest without reopening the APK. Offsets and limits count UTF-16
code units; follow `coverage.next_offset`. The page preserves `source_status` and
`reported_total_bytes`: exhausting retained text does not make a partial decoded
manifest complete. Complete source Evidence remains exportable under the existing
session lifetime. Servers without retention reject summary before starting JADX.
One-shot CLI output remains complete. Older servers require the existing complete
request after schema discovery. APK authenticity and runtime behavior remain
unverified.
## Supply tools
Use a **full JDK 17 or newer** (including its compiler module) and

View File

@ -37,6 +37,11 @@ raw MCP replies from observed client execution/discovery and usage. Optional
trial capture retains tool/usage items while excluding account and ambient state;
existing ephemeral trials keep their default behavior.
Follow-up: [Android package delivery and retained manifest](android-package-summary.md)
records real NewPipe SDK pairs, unchanged old requests/Evidence, exact retained
text, and actual Codex outcomes including client errors and a failed discovery.
Reply bytes fall; model-token/time gains are not established.
Follow-up: [resource counters at payload completion](resource-counters.md) adds
optional owned counter observations while preserving unknown whole-scope final
counters, default receipt shape and the unchanged CPU/memory safeguards.

View File

@ -0,0 +1,166 @@
# Android package summary and exact retained manifest
Continues [#2016](https://github.com/morluto/rea/issues/2016) and
[#2438](https://github.com/morluto/rea/pull/2438). The recurring NewPipe question
asks for APK/package/version identity, all case-sensitive MainActivity matches
and all reported onCreate entries, including explicit null DEX descriptors.
Complete package replies delivered decoded manifest text and duplicate upstream
calls unrelated to those selected facts.
## Compatibility and ownership
MCP `inspect_android_package` accepts explicit `detail: "summary"`; omitted and
`"complete"` requests preserve the complete output and provider request. The
one-shot CLI stays complete. Summary selection happens in the application
workflow, independently of the engine protocol, using the existing retained
analysis-view machinery. No third-party plugin API or persistent analysis cache
is introduced. Servers lacking retention reject summary before provider work.
The complete original Evidence stays under its original ID and session lifetime.
A distinct linked derived view returns all package/SDK/permission declarations,
engine identity, class/resource counts and signature-verification status.
Manifest metadata retains the producer's partial/complete status, nullable
reported byte total, and observed UTF-16 length. Text and duplicate raw calls
remain in the original record, rather than being rewritten under its ID.
Use `inspect_analysis_view` with the parent's ID and
`view: {kind: "android", facet: "manifest", offset: 0, limit: 4096}` to read exact
retained decoded text. Follow `coverage.next_offset`; surrogate boundaries use
the existing text-page implementation. Exhaustion describes the retained text,
not global completeness when the producer says partial. APK signatures and
runtime behavior remain unverified. Engine artifact SHA-256 identifies the JAR;
views require the authenticated APK subject and never substitute the engine
hash for a missing target identity. Older servers require schema discovery and
the original complete request; automatic fallback is not introduced.
## Frozen runtime and real SDK pairs
Source runtime: `28c84e78cf2fbfde4aceed345b773c59eb889f68`; later test-only schema
typing corrections do not relabel these receipts. Compiled dist/bridge file-table
SHA-256: `5332dda13981f043e146b3601ebec1f380d9f698280ca53d75786b638a912e11`.
The frozen old runtime is `8bc1601ca51e417ca472d90ea0367e9418a1a0f2`, digest
`9e7380f537301bf0cfe2926f5619011af848af7c220750e75189498e65d825e1`.
Real NewPipe 0.29.1 bytes, acquisition and digest are pinned in `corpus.json`.
Actual configured JADX headless MCP 0.7.1/Java runs use the same selected
provider settings and search-path digests, one worker and 768 MiB Java heap.
Node is 24.18.0; actual SDK client/server are 2.3.1. Processes and engines are
fresh per trial; host page caches are not flushed. SDK schema mode is selected
in both treatments. Heavy runs are serial, with one CPU, unchanged 3 GiB early
stop/4 GiB hard memory, no swap, and 192 tasks.
All four candidate workflows use three successful public MCP calls. Only the
first package delivery changes. Both pairs pass settings and selected-fact
comparison, including all declarations, manifest status/length, engine identity,
original limitations, matching class rows and onCreate entries. Original replies
and IDs remain separate. A fresh old-runtime complete workflow also passes
comparison against the candidate complete request; both complete package replies
are exactly 119,527 compact client-result JSON bytes. This is scoped compatibility
evidence, not a guarantee for every historical release/client/host.
| Trial | Package reply bytes | SDK workflow seconds | Payload CPU seconds | Kernel peak bytes |
| -------------- | ------------------: | -------------------: | ------------------: | ----------------: |
| sdk-complete-1 | 119,527 | 24.059 | 25.259 | 1,225,076,736 |
| sdk-summary-1 | 8,659 | 23.694 | 24.894 | 1,226,870,784 |
| sdk-summary-2 | 8,659 | 24.407 | 25.611 | 1,228,296,192 |
| sdk-complete-2 | 119,527 | 24.509 | 25.740 | 1,229,844,480 |
Summary replies are 8,659 bytes, about 92.8% smaller; all three replies together
fall from 149,577 to 38,709 bytes. This is a delivery result, not a model-token
or engine-work saving. The SDK timing differences are small. CPU and memory do
not establish a reduction. No provider setting or analysis scope changes.
Transport roundtrip includes server/client work and is not an engine-only timer.
The complete catalog is 2,506,213 compact JSON bytes versus 2,497,635 on the old
runtime: about 0.34% growth, including intervening main changes. Discovery cost
remains visible alongside selected-delivery savings.
## Authentic old Evidence continuation
A real package record from old runtime 8bc160 imports into the candidate and
supports retained summary, exact manifest pages, inline portable summary and
export through actual public SDK tools. The new session explicitly selects an
absent JADX JAR; this workflow performs no package analysis. Eleven successful
calls preserve the original record, original ID, all declarations and all 25,222
manifest UTF-16 units. Manifest UTF-8 SHA-256:
`fee192fe2a6d7892c22cba115184c74c7e2c42ce464e8bb37be6289f2c16881d`.
The exported original record is deeply equal to the imported record.
## Actual Codex task outcomes
Four serial trials run in complete/summary/summary/complete order on the same
frozen candidate. Requested configuration: Codex CLI 0.161.0, `gpt-5.6-terra` high,
three exact selected tool names and the same optional context/discovery capture
settings. The requested alias does not certify the server-resolved model. The
original analyst question is unchanged; equal treatment instructions explicitly
choose package detail and explain the summary carrier. Temporary configuration
points to the frozen modified REA MCP; no global configuration is edited.
| Trial | Input | Cached | Uncached | Output | Seconds | MCP calls | Usage records | Facts |
| ---------------- | ------: | -----: | -------: | -----: | ------: | --------: | ------------: | ----- |
| model-complete-1 | 39,267 | 23,040 | 16,227 | 1,690 | 94.053 | 3 | 3 | Pass |
| model-summary-1 | 128,417 | 93,440 | 34,977 | 3,633 | 150.401 | 8 | 9 | Pass |
| model-summary-2 | 54,437 | 37,120 | 17,317 | 2,185 | 123.677 | 3 | 4 | Pass |
| model-complete-2 | 21,332 | 9,984 | 11,348 | 667 | 20.774 | 0 | 2 | Fail |
Three answers pass the original selected-fact gate against the actual SDK
producer. Each trial preserves APK bytes and removes its isolated profile.
The first summary trial makes five successful calls and three rejected requests:
an invented `case_sensitive` argument, empty class-search input and empty
class-inspection input. It then repeats successful package/search work. These
errors and correction turns remain part of measured task cost; strict existing
schemas correctly reject them. The second summary trial uses the intended three
successful calls, but still does not establish improved total/uncached input or
latency against the factually acceptable first complete control.
The final complete control's first client executor filters `ALL_TOOLS` for the
same three exact names and receives an empty array. It makes zero MCP calls,
reports all requested facts unknown and fails the factual gate. Identical name
filters worked in the other trials. Stderr contains no explanatory error; the
client visibility failure remains unexplained and is not attributed to REA or
summary delivery. Its short duration is not comparable completed work. The
original failed receipt is retained rather than replaced by a successful trial.
These measurements demonstrate no real-model token or speed gain. Smaller raw
MCP envelopes do not establish smaller model context; observed usage also
includes discovery, execution context, corrections and extra responses. Usage
records count captured usage items rather than asserting exact remote request
contents or equating MCP calls with model rounds. The complete default and
opt-in choice remain unchanged; this is useful delivery/retention capability,
not a general performance recommendation.
All model runs have verified payload-completion witnesses with only the observer
remaining and zero memory/task limit events at that phase. Kernel peaks are
602,472,448–1,444,945,920 bytes; the failed no-analysis control is included in that
range and is not evidence of lower analysis memory. Whole-scope final counters
remain unknown and the existing full resource grader stays false. No broader
CPU, memory, OS/provider or model claim is made.
## Resource interpretation
Every SDK run and portable continuation has a verified owned
payload-exited-observer-alive witness, with only the observer remaining and no
memory/task limit events at that phase. Kernel peak and CPU include that opt-in
observer. Whole-scope terminal counters remain unknown; the existing complete
physical resource grader is not changed or called successful. No OOM stress or
resource-limit increase is used.
## Verification and scope
Twenty-seven relevant converted-schema, SDK, domain and existing Android
failure/cancellation/cleanup checks pass. A three-case focused rerun passes after
a test-only schema typing correction. The 1 MiB manifest protocol fixture verifies
original default/full output, reconstruction after source removal, unchanged
exported Evidence and one engine launch. Other cases preserve nulls and partial
status, Unicode boundaries and tagged malformed/missing-identity failures, and
reject unavailable retention or cancelled requests before provider work. These
fixtures verify production boundaries; actual JADX claims use the real NewPipe
runs above. Full supported single-thread compiler and unchanged lint pass under
the same memory safeguards. No other Android OS/device/provider lane is inferred.
Raw SDK/model replies, private profiles and resource receipts stay outside Git.
Reproduce SDK pairs using the existing `measure.mjs` with `--artifact newpipe.apk`,
`--schemas selected`, identical explicit provider configuration and frozen runtime,
and alternating `--delivery complete` / `--delivery summary`. Preserve failed
attempts and run `compare.mjs` on both pair orders. Model findings require their
own identical analyst facts and explicit delivery controls.

View File

@ -666,7 +666,40 @@ const workflows = {
}
},
"newpipe.apk": async () => {
const pkg = await call("inspect_android_package", { path: values.path });
const packageInput = {
path: values.path,
...(values.delivery === "summary" ? { detail: "summary" } : {}),
};
const definition = catalogDefinitions.get("inspect_android_package");
if (
definition === undefined ||
!new Ajv2020({ strict: false, validateFormats: false }).validate(
definition.inputSchema,
packageInput,
)
)
throw new Error(
"Selected Android delivery is not advertised by this runtime",
);
const pkg = await call("inspect_android_package", packageInput);
const result = normalized(pkg);
const report = values.delivery === "summary" ? result.summary : result;
if (
values.delivery === "summary" &&
(result.kind !== "summary" ||
result.parent_operation !== "inspect_android_package")
)
throw new Error("Runtime returned no retained Android package summary");
const { manifest, ...declarations } = report;
const manifestObservation =
values.delivery === "summary"
? manifest
: {
status: manifest.status,
reported_total_bytes: manifest.reported_total_bytes,
text_code_units: manifest.text.length,
text_included: false,
};
const found = await call("search_android_classes", {
path: values.path,
query: "MainActivity",
@ -677,9 +710,12 @@ const workflows = {
});
return {
artifact_sha256: targetSha(pkg),
package_name: normalized(pkg).package_name,
version_name: normalized(pkg).version_name,
version_code: normalized(pkg).version_code,
package_name: report.package_name,
version_name: report.version_name,
version_code: report.version_code,
package_declarations: declarations,
manifest_observation: manifestObservation,
package_limitations: pkg.limitations,
matches: normalized(found).matches,
coverage: normalized(found).coverage,
onCreate: normalized(cls).methods.filter(

View File

@ -0,0 +1,43 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { AndroidAnalysisService } from "./AndroidAnalysisService.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
type AnalysisViewRetention,
} from "../analysisView/AnalysisViewService.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { androidPackageInspectionInputSchema } from "../../domain/android/androidAnalysis.js";
import type { Evidence } from "../../domain/evidence.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, type Result } from "../../domain/result.js";
/** Preserve the complete provider request; select retained delivery explicitly. */
export const inspectAndroidPackage = async (
service: AndroidAnalysisService,
rawInput: unknown,
retention: AnalysisViewRetention,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> => {
const operation = "inspect_android_package";
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
const parsed = androidPackageInspectionInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, {
cause: parsed.error,
}),
);
if (parsed.data.detail === "summary") {
const ready = requireAnalysisViewRetention(operation, retention);
if (!ready.ok) return ready;
}
const executed = await service.execute(
operation,
{ path: parsed.data.path },
options,
);
if (!executed.ok || parsed.data.detail !== "summary") return executed;
return summarizeRetainedAnalysis(executed.value, retention);
};

View File

@ -16,7 +16,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact or inspect_web_network_capture Evidence without repeating analysis. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, native, inventory, WASM, EVM and managed artifact sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package or inspect_web_network_capture Evidence without repeating analysis. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [

View File

@ -1,7 +1,10 @@
import { z } from "zod";
import {
androidInputSchemas,
androidPackageInspectionInputSchema,
androidResultSchemas,
} from "../../domain/android/androidAnalysis.js";
import { androidPackageSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import type { ToolContract } from "../toolContractTypes.js";
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "../toolEffects.js";
@ -12,14 +15,21 @@ export const ANDROID_TOOL_CONTRACTS = [
name: "inspect_android_package",
...toolContractMetadata("inspect_android_package"),
description:
"Inspect a local APK's package, SDK and permission declarations, decoded manifest and class/resource counts. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.",
'Inspect a local APK\'s package, SDK and permission declarations, decoded manifest and class/resource counts. Omitted or complete detail preserves the complete result. Select detail: "summary" for all declarations, engine identity and counts without decoded manifest text or duplicate raw calls; retains the complete source and returns a distinct linked inspect_analysis_view summary. Read the exact retained manifest with view kind android, facet manifest, offset and limit (UTF-16 code units), using normalized_result.parent_evidence_id. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.',
kind: "android-provider",
inputSchema: androidInputSchemas.inspect_android_package,
inputSchema: androidPackageInspectionInputSchema,
outputSchema: evidenceResultOf(
androidResultSchemas.inspect_android_package,
z.union([
androidResultSchemas.inspect_android_package,
androidPackageSummaryViewResultSchema,
]),
),
examples: [
{ title: "Inspect an APK", input: { path: "/tmp/Example.apk" } },
{
title: "Inspect APK declarations with retained manifest",
input: { path: "/tmp/Example.apk", detail: "summary" },
},
],
},
{

View File

@ -56,6 +56,12 @@ import {
wasmSummarySchema,
} from "./wasmArtifactView.js";
import type { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
import {
projectAndroidPackageView,
androidPackageSummarySchema,
androidViewRequestSchema,
androidManifestViewItemSchema,
} from "./androidPackageView.js";
/** Public name of the selected-view workflow. */
export const INSPECT_ANALYSIS_VIEW_OPERATION = "inspect_analysis_view" as const;
@ -169,6 +175,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
wasmViewRequestSchema,
evmViewRequestSchema,
managedViewRequestSchema,
androidViewRequestSchema,
apiSurfaceViewSchema,
]);
@ -283,6 +290,7 @@ const viewResultBase = {
"inspect_wasm_artifact",
"inspect_evm_interface",
"inspect_managed_artifact",
"inspect_android_package",
]),
parent_digest: digestSchema,
view_digest: digestSchema,
@ -340,6 +348,13 @@ export const managedArtifactSummaryViewResultSchema = z.strictObject({
summary: managedSummarySchema,
});
/** Exact package declarations with retained decoded manifest text. */
export const androidPackageSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("inspect_android_package"),
summary: androidPackageSummarySchema,
});
/** Projected facts for one selected view of retained analysis Evidence. */
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
z.strictObject({
@ -362,6 +377,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
wasmSummarySchema,
evmSummarySchema,
managedSummarySchema,
androidPackageSummarySchema,
]),
}),
z.strictObject({
@ -410,6 +426,13 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...managedViewContextSchema.shape,
parent_operation: z.literal("inspect_managed_artifact"),
}),
z.strictObject({
kind: z.literal("android"),
view: androidViewRequestSchema,
item: androidManifestViewItemSchema,
...viewResultBase,
parent_operation: z.literal("inspect_android_package"),
}),
z.strictObject({
kind: z.literal("inventory"),
view: inventoryViewSchema,
@ -480,6 +503,10 @@ export type UnsignedAnalysisView =
| Omit<
Extract<AnalysisViewResult, { readonly kind: "managed" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "android" }>,
"view_digest"
>;
/** Attach the content digest that must change when projected bytes change. */
@ -525,7 +552,7 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, EVM, managed artifact, or web network capture Evidence.`,
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, EVM, managed artifact, Android package, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@ -554,6 +581,7 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_wasm_artifact", projectWasmArtifactView],
["inspect_evm_interface", projectEvmInterfaceView],
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_android_package", projectAndroidPackageView],
["inspect_artifact", projectArtifactInventoryView],
["inventory_artifact", projectArtifactInventoryView],
]);

View File

@ -0,0 +1,136 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { androidResultSchemas } from "../android/androidAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import {
analysisViewInputError,
type AnalysisViewParent,
type AnalysisViewRequest,
type UnsignedAnalysisView,
} from "./analysisView.js";
import { completeWithinViewCoverage } from "./analysisViewCoverage.js";
import { analysisTextPage } from "./analysisViewText.js";
const packageSchema = androidResultSchemas.inspect_android_package;
/** All package declarations remain inline; decoded manifest text stays retained. */
export const androidPackageSummarySchema = packageSchema.extend({
manifest: packageSchema.shape.manifest.omit({ text: true }).extend({
text_code_units: z.number().int().nonnegative(),
text_included: z.literal(false),
}),
});
/** Exact decoded text; offsets and limits count UTF-16 code units. */
export const androidViewRequestSchema = z.strictObject({
kind: z.literal("android"),
facet: z.literal("manifest"),
offset: z.number().int().nonnegative().default(0),
limit: z.number().int().positive(),
});
export const androidManifestViewItemSchema = z.strictObject({
text: z.string(),
unit: z.literal("utf16-code-units"),
source_status: packageSchema.shape.manifest.shape.status,
reported_total_bytes: packageSchema.shape.manifest.shape.reported_total_bytes,
});
/** Project an authenticated package record without reopening the APK or engine. */
export const projectAndroidPackageView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "android")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Android package Evidence supports summary and manifest views.",
},
]),
);
const parsed = packageSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
// Engine artifact_sha256 identifies the JAR, never the APK. A missing
// authenticated APK subject cannot be repaired using the engine identity.
if (parent.subject == null)
return err(
analysisViewInputError([
{
path: ["source", "subject"],
reason: "invalid_value",
message:
"Android package views require the recorded APK subject identity.",
},
]),
);
const report = parsed.data;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_android_package" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
},
limitations: [...parent.limitations],
unknowns: [
"APK signatures were not verified; package declarations do not establish authenticity or runtime behavior.",
...(report.manifest.status === "partial"
? [
"The decoded manifest is partial; exhausting retained text does not establish complete manifest coverage.",
]
: []),
],
};
if (view.kind === "summary") {
const { text, ...manifest } = report.manifest;
return ok({
kind: "summary",
view,
...shared,
summary: {
...report,
manifest: {
...manifest,
text_code_units: text.length,
text_included: false,
},
},
coverage: completeWithinViewCoverage(1, 1),
});
}
const page = analysisTextPage(
report.manifest.text,
view.offset,
view.limit,
"Manifest",
);
if (!page.ok) return page;
return ok({
kind: "android",
view,
...shared,
item: {
...page.value.item,
source_status: report.manifest.status,
reported_total_bytes: report.manifest.reported_total_bytes,
},
coverage: page.value.coverage,
});
};

View File

@ -314,7 +314,7 @@ export const projectJavaScriptApplicationView = (
...shared,
});
}
if (view.collection !== "modules")
if (view.kind !== "page" || view.collection !== "modules")
return err(
jsIncompatible(
"Section and symbol collections apply to inspect_binary_layout Evidence.",

View File

@ -45,6 +45,12 @@ export const androidInputSchemas = {
}),
} as const;
/** Optional retained delivery is separate from the engine's APK request. */
export const androidPackageInspectionInputSchema =
androidInputSchemas.inspect_android_package.extend({
detail: z.enum(["complete", "summary"]).optional(),
});
/** Supported static Android operations. */
export type AndroidOperation = keyof typeof androidInputSchemas;
/** Validated Android requests carry their selected operation. */

View File

@ -515,6 +515,7 @@ const registerConfiguredAnalysisTools = (
toolLogger,
recordEvidence,
withAdmittedAnalysis,
evidenceById,
);
registerJebTools(
server,

View File

@ -1,6 +1,8 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
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";
@ -18,6 +20,7 @@ export const registerAndroidTools = (
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
withAdmittedAnalysis?: WithAdmittedAnalysis,
evidenceById?: EvidenceLookup,
): void => {
const handler = createContractToolHandler(
server,
@ -44,10 +47,26 @@ export const registerAndroidTools = (
withAdmittedAnalysis,
);
const packageContract = toolContract("inspect_android_package");
const packageService: ContractToolService<"inspect_android_package"> = {
execute: (_operation, input, options) =>
inspectAndroidPackage(
service,
input,
{ recordEvidence, evidenceById },
options,
),
};
const packageHandler = createContractToolHandler(
server,
packageService,
logger,
recordEvidence,
withAdmittedAnalysis,
);
server.registerTool(
packageContract.name,
toolRegistrationOptions(packageContract),
handler(packageContract),
packageHandler(packageContract),
);
const searchContract = toolContract("search_android_classes");
server.registerTool(

View File

@ -0,0 +1,295 @@
import { unlink } from "node:fs/promises";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished, vi } from "vitest";
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
import { inspectAndroidPackage } from "../../../src/application/android/AndroidPackageInspection.js";
import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js";
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
import {
androidPackageSummaryViewResultSchema,
analysisViewResultSchema,
} from "../../../src/domain/analysisView/analysisView.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
import { createServer } from "../../../src/server/createServer.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
createJadxProtocolFixture,
verifyJadxFixtureCleanup,
} from "../../fixtures/android/jadx.js";
it.skipIf(process.platform === "win32")(
"retains complete APK Evidence and reads exact manifest without its source or another engine launch",
async () => {
const { provider, apk, launches } =
await createJadxProtocolFixture("large-manifest");
const harness = await createApplicationMcpHarness({
serverOptions: { androidAnalysis: provider },
});
onTestFinished(harness.close);
const { client, session } = harness;
const catalog = (await client.listTools()).tools;
const definition = catalog.find(
(tool) => tool.name === "inspect_android_package",
);
if (definition?.outputSchema === undefined)
throw new Error("Missing APK schema");
const outputSchema: Record<string, unknown> = definition.outputSchema;
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(definition.inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
expect(
ajv.validate(definition.inputSchema, { path: apk, detail: "summary" }),
).toBe(true);
expect(
ajv.validate(
catalog.find((tool) => tool.name === "inspect_android_class")!
.inputSchema,
{
path: apk,
class_name: "fixture.Target",
detail: "summary",
},
),
).toBe(false);
const reply = await client.callTool({
name: "inspect_android_package",
arguments: { path: apk, detail: "summary" },
});
expect(reply.isError).not.toBe(true);
expect(
ajv.validate(outputSchema, reply.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const receipt = parseEvidence(reply.structuredContent);
const view = androidPackageSummaryViewResultSchema.parse(
receipt.normalized_result,
);
const parent = session.evidenceById(view.parent_evidence_id);
if (parent === undefined)
throw new Error("Complete package Evidence not retained");
const report = androidResultSchemas.inspect_android_package.parse(
parent.normalized_result,
);
expect(parent.operation).toBe("inspect_android_package");
expect(parent.parameters).toEqual({ path: apk });
expect(view.artifact.sha256).toBe(parent.subject?.digest.sha256);
expect(view.artifact.sha256).not.toBe(report.engine.artifact_sha256);
expect(receipt.evidence_id).not.toBe(parent.evidence_id);
expect(receipt.evidence_links).toEqual([parent.evidence_id]);
const { manifest, ...declarations } = report;
expect(view.summary).toMatchObject(declarations);
expect(view.summary.manifest).toEqual({
status: manifest.status,
reported_total_bytes: manifest.reported_total_bytes,
text_code_units: manifest.text.length,
text_included: false,
});
expect(receipt.raw_result).toBeNull();
expect(parent.raw_result).not.toBeNull();
for (const detail of [undefined, "complete"]) {
const full = parseEvidence(
(
await client.callTool({
name: "inspect_android_package",
arguments: {
path: apk,
...(detail === undefined ? {} : { detail }),
},
})
).structuredContent,
);
expect(full.operation).toBe("inspect_android_package");
expect(full.normalized_result).toEqual(parent.normalized_result);
expect(full.parameters).toEqual(parent.parameters);
expect(full.raw_result).not.toBeNull();
}
const before = JSON.stringify(parent);
await unlink(apk);
let text = "";
let offset = 0;
do {
const paged = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: parent.evidence_id,
},
view: { kind: "android", facet: "manifest", offset, limit: 700000 },
},
});
expect(paged.isError).not.toBe(true);
const page = analysisViewResultSchema.parse(
parseEvidence(paged.structuredContent).normalized_result,
);
if (page.kind !== "android") throw new Error("Unexpected manifest page");
text += page.item.text;
expect(page.item.source_status).toBe(manifest.status);
expect(page.item.reported_total_bytes).toBe(
manifest.reported_total_bytes,
);
offset = page.coverage.next_offset ?? manifest.text.length;
} while (offset < manifest.text.length);
expect(text).toBe(manifest.text);
const portable = inspectAnalysisView({
source: { kind: "inline", evidence: parseEvidence(JSON.parse(before)) },
view: { kind: "summary" },
});
if (!portable.ok) throw portable.error;
expect(portable.value.normalized_result).toEqual(view);
expect(JSON.stringify(session.evidenceById(parent.evidence_id))).toBe(
before,
);
expect(
session
.exportEvidenceBundle()
.records.find((record) => record.evidence_id === parent.evidence_id),
).toEqual(parent);
expect(launches).toHaveLength(1);
await harness.close();
await verifyJadxFixtureCleanup(launches);
},
30000,
);
it("preserves partial/unknown declarations and Unicode text while rejecting missing APK identity and malformed source", () => {
const report = {
engine: {
name: "jadx",
version: null,
artifact_sha256: "b".repeat(64),
source_revision: null,
worker_count: 1,
heap_limit_mib: null,
},
package_name: null,
version_name: null,
version_code: null,
min_sdk: null,
target_sdk: null,
permissions: [],
manifest: { status: "partial", text: "a😀b", reported_total_bytes: null },
class_count: 0,
resource_count: 0,
signature_verification: "not_performed",
};
const evidence = (result: JsonValue = report, hasSubject = true) =>
createEvidence(
hasSubject
? { path: "/original.apk", sha256: "a".repeat(64), format: "apk" }
: undefined,
{ id: "jadx", name: "JADX", version: "0.7.1" },
{
operation: "inspect_android_package",
parameters: {},
result,
limitations: ["Original limit"],
},
);
const parent = evidence();
const source = { kind: "inline", evidence: parent };
const summary = inspectAnalysisView({ source, view: { kind: "summary" } });
if (!summary.ok) throw summary.error;
const overview = androidPackageSummaryViewResultSchema.parse(
summary.value.normalized_result,
);
expect(overview.summary.package_name).toBeNull();
expect(overview.summary.manifest.status).toBe("partial");
expect(overview.limitations).toEqual(parent.limitations);
const page = inspectAnalysisView({
source,
view: { kind: "android", facet: "manifest", offset: 0, limit: 4 },
});
if (!page.ok) throw page.error;
const result = analysisViewResultSchema.parse(page.value.normalized_result);
expect(result).toMatchObject({
kind: "android",
item: {
text: "a😀b",
source_status: "partial",
reported_total_bytes: null,
},
coverage: { exhausted: true },
});
expect(result.unknowns.join(" ")).toContain("partial");
for (const offset of [2])
expect(
inspectAnalysisView({
source,
view: { kind: "android", facet: "manifest", offset, limit: 2 },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
for (const invalid of [
evidence(report, false),
evidence({ ...report, class_count: -1 }),
])
expect(
inspectAnalysisView({
source: { kind: "inline", evidence: invalid },
view: { kind: "summary" },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it("rejects unavailable retention, invalid selectors and cancellation before provider execution", async () => {
const execute = vi.fn();
const provider = {
execute,
close: async () => {},
inspectAvailability: vi.fn(),
};
const service = new AndroidAnalysisService(provider);
const retention = { recordEvidence: undefined, evidenceById: undefined };
expect(
await inspectAndroidPackage(
service,
{ path: "/missing.apk", detail: "summary" },
retention,
),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisCapabilityUnavailableError" },
});
expect(
await inspectAndroidPackage(
service,
{ path: "/missing.apk", detail: "other" },
retention,
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
expect(
await inspectAndroidPackage(
service,
{ path: "/missing.apk", detail: "summary" },
retention,
{ signal: AbortSignal.abort() },
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
expect(execute).not.toHaveBeenCalled();
const session = createTestBinarySession(() => {
throw new Error("Unexpected deep provider");
});
const server = createServer(
{ kind: "fixed", analysis: session },
{ androidAnalysis: provider },
);
const client = new Client({ name: "android-fixed-summary", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [a, b] = InMemoryTransport.createLinkedPair();
await server.connect(b);
await client.connect(a);
const reply = await client.callTool({
name: "inspect_android_package",
arguments: { path: "/missing.apk", detail: "summary" },
});
expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable");
expect(execute).not.toHaveBeenCalled();
});