From f254f5ac2b804b353a711264bb178f43fdf07867 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:21:53 +0800 Subject: [PATCH] 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> --- docs/android-analysis.md | 21 ++ experiments/analysis-efficiency/README.md | 5 + .../android-package-summary.md | 166 ++++++++++ experiments/analysis-efficiency/measure.mjs | 44 ++- .../android/AndroidPackageInspection.ts | 43 +++ src/contracts/analysisViewToolContracts.ts | 2 +- src/contracts/android/androidToolContracts.ts | 16 +- src/domain/analysisView/analysisView.ts | 30 +- src/domain/analysisView/androidPackageView.ts | 136 ++++++++ .../analysisView/javascriptApplicationView.ts | 2 +- src/domain/android/androidAnalysis.ts | 6 + src/server/createServer.ts | 1 + src/server/registerAndroidTools.ts | 21 +- .../mcp/androidPackageSummaryMcp.test.ts | 295 ++++++++++++++++++ 14 files changed, 777 insertions(+), 11 deletions(-) create mode 100644 experiments/analysis-efficiency/android-package-summary.md create mode 100644 src/application/android/AndroidPackageInspection.ts create mode 100644 src/domain/analysisView/androidPackageView.ts create mode 100644 tests/boundary/mcp/androidPackageSummaryMcp.test.ts diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 5a4a2f879..d98d9b5da 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -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 diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 79bac97e0..8afef3a66 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -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. diff --git a/experiments/analysis-efficiency/android-package-summary.md b/experiments/analysis-efficiency/android-package-summary.md new file mode 100644 index 000000000..448202d87 --- /dev/null +++ b/experiments/analysis-efficiency/android-package-summary.md @@ -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. diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs index 3e34d935e..bfac3adb6 100644 --- a/experiments/analysis-efficiency/measure.mjs +++ b/experiments/analysis-efficiency/measure.mjs @@ -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( diff --git a/src/application/android/AndroidPackageInspection.ts b/src/application/android/AndroidPackageInspection.ts new file mode 100644 index 000000000..3fbaa2f1b --- /dev/null +++ b/src/application/android/AndroidPackageInspection.ts @@ -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> => { + 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); +}; diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 682c49e6b..a4004e85b 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -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: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", 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: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", 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: [ diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index df2cccf59..b555fcbea 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -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" }, + }, ], }, { diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 4ab9c77da..784d5dbed 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -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, "view_digest" + > + | Omit< + Extract, + "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([ ["inspect_wasm_artifact", projectWasmArtifactView], ["inspect_evm_interface", projectEvmInterfaceView], ["inspect_managed_artifact", projectManagedArtifactView], + ["inspect_android_package", projectAndroidPackageView], ["inspect_artifact", projectArtifactInventoryView], ["inventory_artifact", projectArtifactInventoryView], ]); diff --git a/src/domain/analysisView/androidPackageView.ts b/src/domain/analysisView/androidPackageView.ts new file mode 100644 index 000000000..caf948c10 --- /dev/null +++ b/src/domain/analysisView/androidPackageView.ts @@ -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 => { + 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, + }); +}; diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 5e5f9bb88..f4c09a68d 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -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.", diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index e27be2b20..ca4d6db59 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -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. */ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 12ad6418b..e445dcfab 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -515,6 +515,7 @@ const registerConfiguredAnalysisTools = ( toolLogger, recordEvidence, withAdmittedAnalysis, + evidenceById, ); registerJebTools( server, diff --git a/src/server/registerAndroidTools.ts b/src/server/registerAndroidTools.ts index 722bd6695..a8c29a187 100644 --- a/src/server/registerAndroidTools.ts +++ b/src/server/registerAndroidTools.ts @@ -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( diff --git a/tests/boundary/mcp/androidPackageSummaryMcp.test.ts b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts new file mode 100644 index 000000000..960886b02 --- /dev/null +++ b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts @@ -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 = 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(); +});