feat(android): project a structured manifest component inventory (#5025)

* feat(android): project a structured manifest component inventory

* fix(android): derive provider export defaults from the target SDK and match namespaced attributes

* fix(android): bound resolved names and authorities in the component inventory

* fix(android): separate nonliteral exported values from omission

* fix(android): bound manifest component projection

* fix(android): preserve permission override semantics

* fix(android): retain unresolved package identities

* test(android): cover multibyte manifest name budget

* fix(android): measure inherited permission once

* fix(android): preserve unknown symbolic permissions

* fix(android): preserve manifest component uncertainty

* fix(android): report unsupported manifest facets

* fix(android): preserve manifest identities

* fix(android): preserve alias target references

* fix(android): validate alias targets and authorities

* fix(android): bound alias target expansion

* fix(android): index alias target spellings

* fix(android): preserve alias identities and isolate legacy defaults

* fix(android): keep legacy component defaults typed as unavailable

* docs(android): record manifest projection and real JADX costs

* fix(android): preserve SDK defaults and manifest namespaces

* docs(android): verify SDK and namespace ownership corrections

* fix(android): accept DEX class names and hexadecimal SDK levels

* docs(android): record DEX and SDK follow-up verification

* fix(android): validate manifest package identity separately

* docs(android): record final package-identity verification

* fix(android): preserve exact DEX component and alias names

* docs(android): record exact-name integration verification

* fix(android): retain declared foreground service type text

* docs(android): record final declared-value validation

---------

Co-authored-by: BaaRai <baarai@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
BaaRai
2026-10-24 11:14:23 +02:00
committed by GitHub
parent d9660eb032
commit f2fa7f648d
8 changed files with 2307 additions and 1 deletions
+47
View File
@@ -34,6 +34,53 @@ One-shot CLI output remains complete. Older servers require the existing complet
request after schema discovery. APK authenticity and runtime behavior remain
unverified.
## Manifest component projection
`inspect_android_package` also returns a static inventory of declared
activities, aliases, services, receivers and providers from the decoded
manifest. `declared_name` preserves the source attribute; `name` resolves a
relative name only when the manifest package is known and the resolved copy
fits the UTF-8 byte budget. An unresolved package or exhausted name budget
retains the declared name and marks the inventory partial. Empty component
names and names with forbidden DEX characters retain their raw spelling and
are not treated as identities. Valid DEX spaces, including boundary spaces,
are preserved when resolving component names and matching alias targets.
Whitespace in the manifest package is rejected by its separate package grammar.
Component permissions inherit the application
permission when no component permission is declared, except an activity alias
with no permission: Android documents that case as requiring no permission
through the alias. `permission_source` distinguishes a local declaration,
inherited value, no requirement (including an explicit empty override), and a
value omitted because its resource/manifest-placeholder token is unresolved
or the total permission-byte budget was reached. Resource and placeholder
references are not expanded at this parser boundary. Repeated inherited
values share one inventory-wide byte budget.
Provider `android:readPermission` and `android:writePermission` overrides are
not projected as separate fields; their presence marks the inventory partial
and is called out in `limitations`. Relative component names are not resolved
against a manifest package with surrounding whitespace.
Class-name segments follow DEX `SimpleName` characters, including a leading
digit or hyphen, rather than Java source identifier rules. Relative-name
expansion does not prove class existence, DEX-version compatibility or runtime
loadability. SDK defaults accept JADX's decimal and hexadecimal integer
spellings; the original SDK declaration strings remain unchanged.
`exported` is the declared literal `true` or `false`, otherwise null.
`exported_default` is the documented default only where it can be established;
it remains null for unknown provider target SDKs, nonliteral declarations, or
components with filters targeting API 31 or later that omit the now-required
explicit declaration. A limitation records the latter validation error.
Provider authorities and all inventory dimensions are bounded. `status:
"partial"` and `limitations` identify omitted declarations or values; a
capacity limitation appears only when an actual collection limit was reached.
The projection does not merge manifests, expand resource references, or
observe runtime enabled/export state.
The service `foreground_service_type` field preserves the declared attribute,
including an empty value or surrounding spaces; `null` means the attribute is
absent or the component is not a service. This field does not validate service
type tokens or establish runtime eligibility.
## Supply tools
Use a **full JDK 17 or newer** (including its compiler module) and
@@ -0,0 +1,359 @@
# Android manifest components verification, 2026-10-24
## Scope and revisions
PR #5025 adds a static component inventory to `inspect_android_package`, using
the namespace-aware decoded manifest already retained by the JADX adapter.
Activities, aliases, services, receivers and providers retain declarations,
qualified class names where established, export declarations/defaults,
permission provenance, intent actions/categories, service types, authorities
and alias targets. These facts do not establish runtime registration, class
existence, merged-manifest acceptance or APK authenticity.
The maintenance comparison pins baseline
`e3802c16491b772118ccdfa195c6b51c3a337bdd` (contributor head
`c822b00c4755e584ee081eb3e7587735b630e249` integrated with main) and candidate
`fde65b1d47c00c189e003c54ad2a2c1fc93b443a`. Both contain the feature;
this comparison checks the maintenance changes rather than estimating the
cost of answering the same question without the feature. Later main
integration is outside these measurements.
## Corrections and automated coverage
Alias target matching indexes valid spellings once for preceding activities.
It now adds a bare abbreviation only for a class directly inside the manifest
package. `sub.Target` is already a fully qualified name and cannot match
`com.example.sub.Target`; `.sub.Target` and the complete qualified name can.
The contributor had already removed repeated package expansion in
`c822b00c`; that performance correction is not attributed to this follow-up.
The existing 900 KiB package / 511 alias regression remains covered.
Older Evidence without this inventory now receives a fresh unavailable
inventory per parse. Mutating one parsed default cannot affect another parse.
Before the corrections, the new identity/default cases had 6 passes and 3
failures (two false alias matches and one shared default). Afterward all 50
component cases passed. The broader selected component/JADX/SDK-summary set
passed all 83 cases, and `npm run check:fast` passed on the measured candidate.
After integrating main `21a20306b4887ff8e6b614b6ae34a387cfbdf1d8` in
`cdc2829131045d85692cf4bbf0acb12f36f65af7`, the same 83 cases passed again.
`check:fast`, `docs:check` and test discovery (2,333 files, each once) also passed.
The parser bounds declarations and retained name/permission/authority copies
before expansion. Truncation, unknown export rules, unresolved names and
unsupported permission overrides remain explicit. The existing Android
summary includes the structured inventory and retains the complete manifest
for paging; no second inventory parser or new view implementation was added.
## Real engine and public APK
Linux x86-64, Node 24.22.1 / npm 11.16.0, existing full OpenJDK 17.0.20.1,
JADX headless MCP 0.7.1, one analysis worker, 512 MiB JVM heap and
`ActiveProcessorCount=2`. The task process family was restricted to CPUs 0/1.
The APK was read as an inert artifact and was never installed or executed.
- [Appium ApiDemos 6.0.18](https://github.com/appium/android-apidemos/releases/tag/v6.0.18),
source revision `3e716ee04ef3390284bfab761e28da8c15c08143`, Apache-2.0:
`ApiDemos-debug.apk`, 6,521,217 bytes, SHA-256
`a9eecf37b26cd084855c530db81c2bb1b91f4c1b095a04f47aa7c20e2791f686`.
- [JADX headless MCP 0.7.1](https://github.com/1013503897/jadx-headless-mcp/releases/tag/v0.7.1),
source revision `5844800a486d2248fa949b74c9896285f5b54de2`, JAR SHA-256
`6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75`.
The engine reported package `io.appium.android.apis`, target SDK 33,
2,876 classes and 869 resources. The complete 152,541-code-unit manifest
produced 355 components: 331 activities, 11 services, 7 receivers, 4 providers
and 2 aliases. Component coverage was complete, with no projection limitations.
APK signature verification remained explicitly `not_performed`.
An independent Python ElementTree projection checked every component field,
declaration order, namespace attribute, class-name qualification, permission
source, export/default, intent value, authority and preceding alias target
against the retained XML on all six measured CLI tasks. It also rehashed the
APK. This oracle checks REA's projection of JADX-decoded XML; it does not
independently validate binary XML decoding or Android runtime behavior.
Interpretation was checked against Android's
[activity](https://developer.android.com/guide/topics/manifest/activity-element),
[alias](https://developer.android.com/guide/topics/manifest/activity-alias-element),
[service](https://developer.android.com/guide/topics/manifest/service-element),
[receiver](https://developer.android.com/guide/topics/manifest/receiver-element)
and [provider](https://developer.android.com/guide/topics/manifest/provider-element)
documentation.
An actual stdio MCP client launched the same engine, validated the advertised
input/output schemas with Ajv2020, checked every structured summary fact,
reconstructed the exact manifest in three 70,000-code-unit pages, made a warm
complete call, and re-read the retained summary. All normalized facts and
provenance matched CLI. Raw engine snapshots and Evidence IDs were not equated:
they truthfully retain session-specific private APK paths, Java map key order,
load duration and the commands actually run. The warm complete request does
not repeat the earlier load command. This client explicitly allowed a 64 MiB
receive buffer; default client-buffer compatibility was not established.
## Completed-task costs
Three alternating before/after pairs ran
`rea inspect-android-package <same APK> --json`. Each task launched a fresh CLI
and JVM. OS/JAR caches were already warm; cold disk and first bridge compilation
were not isolated. Full normalized results, target identity and provenance
were equal; raw per-run engine records were retained without rewriting them.
| Cost | Before median (range) | After median (range) |
| ------------------------------------ | -------------------------- | -------------------------- |
| Estimated task tokens | 135,958 (same in all runs) | 135,958 (same in all runs) |
| Elapsed seconds | 16.538 (14.666–37.511) | 23.008 (17.756–24.777) |
| CPU seconds | 18.811 (18.034–19.461) | 18.191 (17.741–18.426) |
| Sampled peak process-family RSS, MiB | 1,074.9 (989.7–1,087.9) | 1,100.4 (1,074.1–1,123.6) |
The elapsed and CPU ranges overlap; these runs establish no speed improvement.
The candidate's median sampled memory was slightly higher. Tokens use tokenx
1.3.0 estimates, not model billing: all complete CLI stdout plus the invocation
(135,926 + 32 tokens). Discovery, prompts, client parsing and envelopes are
outside this known CLI task.
The actual MCP verification task took 132.448 seconds, 40.767 CPU seconds and
1,820.8 MiB sampled peak family RSS, including handshake, full discovery,
schema validation, summary, three manifest pages, complete call, retained
summary and shutdown. An earlier verification attempt used an incorrect
cross-session raw-Evidence equality assertion and failed after 36.131 seconds;
that diagnostic attempt is not counted as a successful workflow.
For the successful MCP task, compact JSON payloads estimate 47,583 tokens for
the summary versus 135,759 for the warm complete Evidence. These are individual
payload estimates, not a measured whole-agent saving; the 222-tool discovery,
envelopes, repeated text/structured representations and earlier attempt are
not included in that payload comparison. CLI and MCP transports have different
costs and are not interchangeable baselines.
CPU comes from waited child-process accounting. RSS samples descendants every
50 ms, may count shared pages more than once and may miss short-lived children;
it is not a kernel-enforced process-family maximum. No Android device,
split APK, signature-verification, native-library or runtime coverage is claimed.
Issue #4448's broader enabled/process, metadata, URI-grant and location requests
remain open.
## SDK omission and XML namespace follow-up
The subsequent review fixes are pinned in
`dfdba0fac79e07949b12299c85a1a2f3e9c2f115`.
Android's [uses-sdk contract](https://developer.android.com/guide/topics/manifest/uses-sdk-element)
defaults an omitted target to the minimum SDK and an omitted minimum to 1.
The component XML owner now combines the bridge's declaration values with
attribute-presence facts from the already parsed document. A present-empty
attribute or an unrecognized nonempty value stays unknown. Declaration-level
`target_sdk` and `min_sdk` remain unchanged. Root child nodes are reused, and
the effective numeric SDK/provider default is computed once; the redundant
per-provider parsing helper was removed.
One unnamespaced-element predicate now guards the manifest root, uses-sdk,
application, components, filters, actions and categories. Foreign lookalikes
cannot contribute component facts or satisfy an alias's preceding target.
Namespace URIs on Android attributes continue to govern attribute selection.
Eleven new negative cases fail on the published baseline, with three positive
SDK cases already passing. Three further present-empty SDK cases fail with a
simple string fallback and pass with the owned presence facts. Unknown-SDK
fixtures now supply an explicit unrecognized value rather than using omission
to mean unknown. All 100 selected component/JADX/protocol/SDK-summary cases
pass, as do `check:fast`, `format:check`, `docs:check` and discovery of all
2,333 files. Complete inventory coverage does not establish every SDK or
export state; nullable facts and limitations retain that uncertainty.
Another three alternating public-APK pairs compare published
`b50ee503fdff0912c12f1aaa76f043235d187a77` with the corrected revision above.
Every normalized fact/provenance field and all 355 component rows match the
independent oracle again. The exact-raw-record exclusions and fresh-JVM,
warm-OS-cache conditions described above apply.
| Cost | Before median (range) | After median (range) |
| ---------------------------------- | ----------------------- | ------------------------- |
| Estimated complete CLI task tokens | 135,958 (constant) | 135,958 (constant) |
| Elapsed seconds | 12.036 (11.133–12.487) | 11.024 (10.623–17.519) |
| CPU seconds | 18.436 (18.169–18.897) | 17.921 (17.252–19.856) |
| Sampled peak family RSS, MiB | 1,022.1 (981.3–1,049.8) | 1,043.8 (1,023.5–1,090.1) |
The ranges overlap and sampled memory median is higher; no speed or memory
improvement is established by this comparison. The actual stdio MCP workflow
also passes again, including all summary facts, the three manifest pages,
warm complete inspection and retained summary. That completed verification
took 26.991 seconds, 35.482 CPU seconds and 1,740.9 MiB sampled peak family RSS.
Its schema/client-buffer and comparison qualifications above remain in force.
These legacy-SDK edge cases are production-boundary/golden checks; the real
APK has an explicit SDK 33 target, so a legacy APK without uses-sdk was not
independently exercised through binary XML decoding or on a device.
## DEX names and hexadecimal SDK follow-up
Commit `27b7dc1656b66a71796d6f0d1119d79be7ab586a` corrects two additional
interpretation boundaries. Class identity expansion accepts the documented
[DEX SimpleName character ranges](https://source.android.com/docs/core/runtime/dex-format#simplename),
including leading digits, hyphens and supplemental Unicode, instead of Java
source identifier categories. The same predicate supplies activity and alias
reference matching. Package declaration validation remains separate. Spaces
are admitted by the character union introduced in DEX 040; this decoded
manifest projection does not establish a DEX version, class existence or
runtime loadability.
SDK interpretation now accepts decimal or hexadecimal positive signed-32-bit
resource integers once per inventory. [JADX's typed value decoder](https://github.com/skylot/jadx/blob/v1.5.3/jadx-core/src/main/java/jadx/core/xmlgen/entry/ValuesParser.java#L84)
prints `TYPE_INT_HEX` with a hexadecimal prefix. The unchanged Java bridge
retains those declaration strings; only the local numeric interpretation used
for default rules changes. Unrecognized previews, malformed hex, exponent
syntax, nonpositive values and signed-int overflow remain unknown. Omission
and present-empty handling from the prior correction remains intact.
Nine new controls fail before the correction: four legal DEX name/alias
combinations, three hexadecimal target levels, hexadecimal minimum fallback,
and decimal signed-int overflow. The former malformed-name fixture incorrectly
rejected a digit-led class; it now uses forbidden descriptor separators
instead. The component/MCP selection passes 77 checks. `check:fast`, complete
formatting and documentation pass. The first combined 117-case selection has
116 passes and one 30-second MCP timeout; the unchanged MCP file then passes
all three cases in isolation, with its complete retained-manifest workflow
finishing in 16.957 seconds. That workflow supplies a 1 MiB non-XML string;
the changed class and SDK interpreters are not reached for that input. No
deadline was raised. Further combined verification is recorded below.
Six alternating completed CLI tasks compare the immediately preceding head
`58b71c19b35204ebb37c94d9510025bcd18fc4ee` with this correction using the
unchanged ApiDemos APK. Every projected field for all 355 components matches
the independent decoded-XML oracle, and all normalized facts and provenance
agree. Per-session raw engine observations and their Evidence IDs are excluded
for the same documented reason as the previous comparison. Estimated full CLI
stdout plus invocation tokens remain **135,958** (tokenx 1.3.0).
Elapsed medians are 19.619 -> 17.893 s (ranges 12.708–23.907 /
13.658–30.088), waited CPU 30.279 -> 29.507 s (19.684–32.572 /
24.001–37.071), and sampled process-family RSS 1094.6 -> 1064.9 MiB
(985.0–1106.2 / 1038.3–1108.5). Every range overlaps; the environment varies
substantially and these runs establish no speed or memory improvement. CLI
and JVM processes are fresh, with warm OS/JAR caches; cold-disk and fresh
bridge-compilation costs remain unmeasured. The actual stdio SDK workflow also
passes summary, three manifest pages, warm complete output, retained summary
and advertised-schema validation again: 72.614 s elapsed, 78.638 s CPU and
1693.3 MiB sampled family RSS. Its full schema/catalog/transport token cost is
not measured. That workflow validates the existing APK's explicit SDK 33
values; no binary-XML hex fixture or numeric-led DEX class was executed or
qualified on a device. The new edge cases are boundary controls supported by
the format/decoder sources, not claims of real-device coverage.
The subsequent unchanged full selection passes **all 117 cases** in 67.186 s
elapsed, 52.269 s waited CPU and 2028.3 MiB sampled family RSS. This rerun
does not erase the earlier timeout recorded above.
## Android package identity correction
The final pre-merge review found that the package qualifier still used Java
identifier categories. Five new negative controls fail before correction:
`com._hidden`, `com.$hidden`, `com.Δelta`, an ordinary single-segment package,
and a name longer than Android's 223-character parser limit. Existing negatives
also cover digit-led and empty segments. Package qualification now has its own
Android application-ID grammar: ASCII letter-led segments, ASCII letters/digits/
underscores, at least one separator, and the platform `android` exception.
Unsupported recorded package/name strings remain source evidence; relative
component identities and alias references remain unresolved with partial coverage.
DEX class-name rules stay separate and unchanged. This does not claim that a
syntactically supported package or component is installable or loadable.
Authorities are the [Android application-ID documentation](https://developer.android.com/build/configure-app-module#set-application-id),
and Android 16's pinned
[package parser](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/android/content/pm/parsing/ApkLiteParseUtils.java#916)
and [name validator](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/android/content/pm/parsing/FrameworkParsingPackageUtils.java#58).
The documentation requires nonempty segments; the pinned parser is permissive
about empty segments, which this projection leaves unsupported rather than
claiming package identity from them. No Package Manager/device execution was
performed for these cases.
Four existing amplification/copy-budget controls used invalid huge or Unicode packages.
Three now use a valid package and huge ASCII/multibyte component names, preserving
aggregate exhaustion, first-row admission and UTF-8 expansion coverage at the
correct producing boundary. The fourth preserves 511 unresolved aliases under a huge unsupported package without expanding it. The obsolete Java package predicate is removed;
package interpretation is shared by component and alias qualification.
Earlier real-ApiDemos measurements remain pinned to their stated source commits;
they are not measurements of this package correction. Response schemas and
valid ApiDemos facts are unchanged by source reasoning; no fresh speedup or
whole-agent cost claim is made for this correctness change.
Final executed source pin `7b86379e63448391973098572b884b692fbf5d8f`
incorporates main `98ec00f4068bab894639d0d1d83ac0bfc428bcbf`. All **129**
selected component/JADX/protocol/MCP checks pass: 47.290 s elapsed,
41.923 CPU s, 1,486.0 MiB sampled peak process-family RSS. The retained-manifest
MCP case passes with its existing 30-second deadline. Type/lint, full formatting
and generated-document checks also pass. The preceding attempt had 127 passes,
one obsolete huge-package identity expectation and one 30-second MCP timeout;
these attempted costs are not excluded from the narrative. The fixture expectation
is corrected as described above. No deterministic product failure was established
from the timeout and no deadline was increased. This completed selection uses
primed OS/file caches and a fresh runner; it is a validation cost, not a
before/after performance comparison. Family-memory sampling limitations above
remain applicable.
## Exact DEX class spelling follow-up
A later review identified another identity/display conflation: DEX format 040
permits U+0020 in SimpleName, including its boundaries, but component and alias
handling still trimmed it. Five source-boundary regressions fail before the
correction (87 other cases pass); all 92 component cases pass after it. They
cover trailing space, leading space, spaces after a relative dot, a space-only
segment, and a fully qualified spelling with boundary spaces. A separate control
keeps `.Hidden` distinct from `.Hidden ` in alias matching.
The XML owner now validates and resolves the original component spelling and
compares exact alias references. The redundant raw/trimmed representations and
the downstream trim-based invalidity checks are removed. The existing malformed
boundary control now uses XML character-reference tabs, which DEX disallows,
rather than incorrectly treating U+0020 as invalid. Empty names and the descriptor
separator controls remain covered. Package grammar retains its separate owner.
The domain schema and retained summary consume these strings without another
normalization step.
The source authorities are [DEX SimpleName](https://source.android.com/docs/core/runtime/dex-format#simplename)
and Android 16's [buildClassName](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/com/android/internal/pm/pkg/parsing/ParsingUtils.java),
which applies the package to the exact string. These are decoded-XML and
source-qualified cases. No authentic space-named DEX APK or device load was
executed; the decoded manifest alone does not establish DEX version, class
existence or loadability. Earlier real-ApiDemos measurements keep their exact
source pins and are not re-labelled as measurements of this correction. The
public response schema and ordinary valid-name output are unchanged by source
reasoning; no new token or speed improvement is claimed.
Executed space-preservation source pin `d1f15f68f65bc546590000eee86c91b1e061bf55`
incorporates merged IL2CPP/main `3ce311757dd8ac444c8297b2871464536f93816d`.
All **135** component/JADX/protocol/MCP checks pass without changing deadlines:
47.168 s elapsed, 43.081 CPU s and 1,550.0 MiB sampled peak family RSS.
Type/lint, full formatting and generated-document checks pass. Earlier combined
integration had 672 passes and one Android 30-second MCP timeout; the unchanged
three-case MCP file passed separately at the original deadline. These outcomes
remain distinct. This repeat is a fresh runner with primed OS/file caches;
validation costs are not evidence of a general performance improvement.
## Final declared-value review
Source pin `d87801811cc80dd683f482a76f9cb9ad1ab9b670` removes the last `trimmed` helper. Service
`foreground_service_type` now preserves the declared Android attribute exactly:
absence is null, while explicit empty, space-only, padded, token-list and
resource-reference spellings remain distinct. This field records a declaration;
it does not validate service-type tokens or establish runtime eligibility.
The component guide now distinguishes valid DEX boundary spaces from invalid
controls and from the separate manifest-package grammar.
Six source-boundary cases were added. Against the previous implementation,
95 checks pass and 3 fail (empty, space-only and padded declarations). After the
owner fix, all **141** selected component/JADX/protocol/MCP checks pass at the
original deadlines. The newly malformed foreground-type values are verified at
the parser boundary; the existing SDK retention workflows do not constitute a
real Android runtime verification of those values.
The selected suite took 51.269 s elapsed,
41.298 CPU s and
1632.789 MiB sampled peak family RSS.
`check:fast`, full `format:check` and `docs:check` pass. Earlier real ApiDemos
measurements retain their stated source pins; no new performance improvement
or real DEX 040 space-named class verification is claimed.
At the superseded `d799bafa5f2f7c563da04955b540345f7bae70e4` CI run,
the source worker exited while starting a JavaScript reference-lifetimes test;
there was no failed Android assertion or complete source-suite result. That
JavaScript source tree is byte-identical to the integrated main source. This is
an observed worker failure, not a reproduced main failure or a passing check.
The final head requires its own CI assessment.
+701
View File
@@ -0,0 +1,701 @@
import type { Element, Node } from "@xmldom/xmldom";
import { parseXmlDocument } from "../domain/xmlDocument.js";
/** Component kinds a decoded AndroidManifest can declare under <application>. */
export const ANDROID_COMPONENT_KINDS = [
"activity",
"activity-alias",
"service",
"receiver",
"provider",
] as const;
export type AndroidComponentKind = (typeof ANDROID_COMPONENT_KINDS)[number];
/** Bounds keep a hostile manifest from exhausting the inspection. */
export const ANDROID_MANIFEST_LIMITS = {
components: 512,
intentFilters: 32,
intentValues: 64,
authorities: 64,
/** Total bytes of resolved component names, bounding package amplification. */
resolvedNameBytes: 1024 * 1024,
authorityBytes: 1024 * 1024,
/** Total copied permission-value bytes, including inherited defaults. */
permissionBytes: 1024 * 1024,
} as const;
/** The attribute namespace every component facet uses. */
const ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
export interface AndroidManifestComponent {
readonly kind: AndroidComponentKind;
/** Class name as written in the manifest. */
readonly declared_name: string;
/**
* Fully qualified name with the manifest package applied, or the declared
* name unchanged when it cannot be resolved or its syntax is not
* established.
*/
readonly name: string;
/**
* Declared android:exported, or null when the attribute is absent or set
* to a value other than the literals true or false, whose effective state
* the parser cannot resolve.
*/
readonly exported: boolean | null;
/**
* Android's default export state when android:exported is absent: the
* intent-filter rule for activities, services, receivers and aliases, and
* the target-SDK rule for providers. Null when the provider rule cannot be
* evaluated because the target SDK is unknown, or when the attribute is
* present but not a literal boolean, since the default does not govern a
* declared value.
*/
readonly exported_default: boolean | null;
readonly permission: string | null;
/** Source of the effective permission projection; unknown when its copy is capped. */
readonly permission_source: "component" | "application" | "none" | "unknown";
readonly intent_filter_count: number;
readonly actions: readonly string[];
readonly categories: readonly string[];
/** Declared android:foregroundServiceType on services; null otherwise. */
readonly foreground_service_type: string | null;
/** Exact declared provider authority segments, split on ";"; malformed segments remain raw. */
readonly authorities: readonly string[] | null;
/** Exact declared android:targetActivity on activity-alias; malformed references remain raw and make the inventory partial. */
readonly target_activity: string | null;
}
export interface AndroidManifestComponentInventory {
readonly status: "complete" | "partial" | "unavailable";
readonly items: readonly AndroidManifestComponent[];
readonly limitations: readonly string[];
}
const PARTIAL_LIMITATION =
"The decoded manifest text is truncated, so its component inventory is not established; the retained manifest is partial.";
const unavailable = (
limitation: string,
): AndroidManifestComponentInventory => ({
status: "unavailable",
items: [],
limitations: [limitation],
});
const childNodes = (element: Element): readonly Node[] =>
Array.from(element.childNodes);
/** Manifest tags are unnamespaced; Android's namespace qualifies attributes. */
const isManifestElement = (node: Node | null): node is Element =>
node !== null &&
node.nodeType === 1 &&
(node as Element).namespaceURI === null;
/** Read an unnamespaced attribute, the shape manifest `package` uses. */
const plainAttribute = (element: Element, name: string): string | null => {
const value = element.getAttribute(name);
return value === "" ? null : value;
};
/**
* Read an android-namespaced attribute. XML prefixes are aliases, so a
* same-local-name attribute from another namespace is never a match.
*/
const androidAttribute = (
element: Element,
localName: string,
): string | null => {
const attributes = element.attributes;
for (let index = 0; index < attributes.length; index += 1) {
const node = attributes.item(index);
if (node === null || node.localName !== localName) continue;
if (node.namespaceURI === ANDROID_NAMESPACE) return node.nodeValue;
// Without namespace information (an undeclared prefix), the conventional
// android: prefix is the only remaining signal.
if (node.namespaceURI === null && node.prefix === "android")
return node.nodeValue;
}
return null;
};
/** Android application-ID grammar, with the platform package exception. */
const hasSupportedAndroidPackageNameSyntax = (name: string): boolean =>
name === "android" ||
(name.length <= 223 &&
/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u.test(name));
// DEX SimpleName permits digits at the start, '-', supplemental code points,
// and (since format 040) spaces. A decoded manifest does not qualify DEX
// version or class existence; accept the documented character union here.
const dexSimpleNameCharacter =
/^[A-Za-z0-9$_\- \u00a0-\u1fff\u2000-\u200a\u2010-\u2027\u202f-\ud7ff\ue000-\uffef\u{10000}-\u{10ffff}]$/u;
const hasSupportedDexBinaryClassNameSyntax = (name: string): boolean => {
let segmentStart = true;
let first = true;
for (const character of name) {
if (first && character === ".") {
first = false;
continue;
}
first = false;
if (character === ".") {
if (segmentStart) return false;
segmentStart = true;
} else {
if (!dexSimpleNameCharacter.test(character)) return false;
segmentStart = false;
}
}
return !segmentStart;
};
const containsUnresolvedToken = (value: string): boolean =>
value.includes("@") || value.includes("?") || value.includes("${");
/**
* Android resolves a component name against the manifest package: a leading
* dot or a name without any dot is relative to the package, while a dotted
* name is already fully qualified.
*/
export const resolveAndroidComponentName = (
packageName: string | null,
declared: string,
): string => {
if (declared.startsWith(".")) return `${packageName ?? ""}${declared}`;
if (!declared.includes(".") && packageName !== null)
return `${packageName}.${declared}`;
return declared;
};
const isComponentKind = (tag: string): tag is AndroidComponentKind =>
(ANDROID_COMPONENT_KINDS as readonly string[]).includes(tag);
const androidTargetSdkLevel = (targetSdk: string | null): number | null => {
// JADX prints TYPE_INT_HEX as 0x..., while TYPE_INT_DEC stays decimal.
// Both originate in an Android signed 32-bit resource integer.
if (targetSdk === null || !/^(?:[0-9]+|0x[0-9a-f]+)$/iu.test(targetSdk))
return null;
const level = Number(targetSdk);
return Number.isInteger(level) && level >= 1 && level <= 0x7fffffff
? level
: null;
};
/**
* Collect unique intent-filter actions and categories. The value budget is
* shared across both arrays, matching the stated per-component bound.
*/
const intentValues = (
filters: readonly Element[],
): { actions: string[]; categories: string[]; truncated: boolean } => {
const actions: string[] = [];
const categories: string[] = [];
let truncated = filters.length > ANDROID_MANIFEST_LIMITS.intentFilters;
let budget = ANDROID_MANIFEST_LIMITS.intentValues;
for (const filter of filters.slice(0, ANDROID_MANIFEST_LIMITS.intentFilters))
for (const member of childNodes(filter)) {
if (!isManifestElement(member)) continue;
const element = member;
if (element.nodeName !== "action" && element.nodeName !== "category")
continue;
const value = androidAttribute(element, "name");
if (value === null) continue;
const target = element.nodeName === "action" ? actions : categories;
if (target.includes(value)) continue;
if (budget <= 0) {
truncated = true;
continue;
}
budget -= 1;
target.push(value);
}
return { actions, categories, truncated };
};
/** Split a provider authorities declaration within its own bound. */
const providerAuthorities = (
declared: string,
byteLimit: number,
): {
authorities: string[];
truncated: boolean;
bytes: number;
malformed: number;
} => {
const authorities: string[] = [];
let truncated = false;
let bytes = 0;
let malformed = 0;
let start = 0;
while (start <= declared.length) {
const separator = declared.indexOf(";", start);
const end = separator < 0 ? declared.length : separator;
const value = declared.slice(start, end);
const valueBytes = Buffer.byteLength(value);
if (
authorities.length >= ANDROID_MANIFEST_LIMITS.authorities ||
valueBytes > byteLimit - bytes
) {
truncated = true;
break;
}
if (value.length === 0 || value !== value.trim()) malformed += 1;
authorities.push(value);
bytes += valueBytes;
if (separator < 0) break;
start = separator + 1;
}
return { authorities, truncated, bytes, malformed };
};
/**
* Parse the decoded AndroidManifest XML text into a bounded, deterministic
* component inventory. The projection is static: it reports declarations and
* Android's documented export defaults, never merged-manifest or runtime
* state.
*/
export const parseAndroidManifestComponents = (
manifestText: string,
coverage: "complete" | "partial",
declaredTargetSdk: string | null = null,
minimumSdk: string | null = null,
): AndroidManifestComponentInventory => {
if (coverage === "partial")
return {
status: "partial",
items: [],
limitations: [PARTIAL_LIMITATION],
};
const parsed = parseXmlDocument(manifestText, {
doctype: "reject",
lineEndings: "preserve",
});
if (!parsed.ok)
return unavailable(
`The decoded manifest is not well-formed XML (${parsed.error.reason}), so its component inventory is unavailable.`,
);
const root = parsed.value.documentElement;
if (!isManifestElement(root) || root.nodeName !== "manifest")
return unavailable(
"The decoded manifest root is not <manifest>, so its component inventory is unavailable.",
);
const rawPackageName = plainAttribute(root, "package");
const packageName = rawPackageName?.trim() || null;
const packageHasInvalidWhitespace =
rawPackageName !== null && rawPackageName !== rawPackageName.trim();
const packageHasUnresolvedToken =
packageName !== null && containsUnresolvedToken(packageName);
const packageHasUnsupportedPackageSyntax =
packageName !== null &&
!packageHasUnresolvedToken &&
!hasSupportedAndroidPackageNameSyntax(packageName);
const packageNameBytes =
packageName === null ? 0 : Buffer.byteLength(packageName);
const limitations: string[] = [];
const items: AndroidManifestComponent[] = [];
let truncated = false;
let applicationSeen = false;
let unnamed = 0;
let providerDefaultsUnknown = 0;
let nonliteralExported = 0;
let unresolvedNames = 0;
let unresolvedPackages = 0;
let unresolvedTargetActivities = 0;
let unresolvedTokens = 0;
let invalidNames = 0;
let malformedAuthoritySegments = 0;
let providerPermissionOverridesUnknown = 0;
let invalidExported = 0;
let unknownFilteredExportDefaults = 0;
let capacityTruncated = false;
let permissionBudgetLimited = false;
let unresolvedPermissionTokens = 0;
let nameBudget = ANDROID_MANIFEST_LIMITS.resolvedNameBytes;
let authorityBudget = ANDROID_MANIFEST_LIMITS.authorityBytes;
let permissionBudget = ANDROID_MANIFEST_LIMITS.permissionBytes;
const children = childNodes(root);
let targetSdkDeclared = false;
let minimumSdkDeclared = false;
for (const child of children) {
if (!isManifestElement(child) || child.nodeName !== "uses-sdk") continue;
// Match the bridge's declaration owner: the last uses-sdk supplies values.
targetSdkDeclared = androidAttribute(child, "targetSdkVersion") !== null;
minimumSdkDeclared = androidAttribute(child, "minSdkVersion") !== null;
}
// Empty protocol values can mean omission or an empty declared attribute.
// XML presence disambiguates them; an explicit unknown never becomes API 1.
let effectiveTargetSdk = declaredTargetSdk;
if (effectiveTargetSdk === null && !targetSdkDeclared) {
effectiveTargetSdk = minimumSdk;
if (effectiveTargetSdk === null && !minimumSdkDeclared)
effectiveTargetSdk = "1";
}
const targetSdkLevel = androidTargetSdkLevel(effectiveTargetSdk);
// Parse the effective SDK once even when hundreds of providers share it.
const providerDefault = targetSdkLevel === null ? null : targetSdkLevel <= 16;
for (const node of children) {
if (!isManifestElement(node)) continue;
const element = node;
if (element.nodeName !== "application") continue;
applicationSeen = true;
const precedingActivityTargetNames = new Set<string>();
const declaredApplicationPermission = androidAttribute(
element,
"permission",
);
const applicationPermission =
declaredApplicationPermission === null ||
declaredApplicationPermission.length === 0
? null
: declaredApplicationPermission;
const applicationPermissionHasUnresolvedToken =
applicationPermission !== null &&
containsUnresolvedToken(applicationPermission);
const applicationPermissionBytes =
applicationPermission === null
? null
: Buffer.byteLength(applicationPermission);
for (const child of childNodes(element)) {
if (!isManifestElement(child)) continue;
const component = child;
if (!isComponentKind(component.nodeName)) continue;
// Every declaration counts against the bound, named or not, so an
// unnamed flood cannot exceed it while still reporting complete.
if (items.length + unnamed >= ANDROID_MANIFEST_LIMITS.components) {
truncated = true;
capacityTruncated = true;
break;
}
const declared = androidAttribute(component, "name");
if (declared === null) {
unnamed += 1;
continue;
}
const unresolvedName = containsUnresolvedToken(declared);
const filters = childNodes(component).filter(
(entry): entry is Element =>
isManifestElement(entry) && entry.nodeName === "intent-filter",
);
const collected = intentValues(filters);
if (collected.truncated) {
truncated = true;
capacityTruncated = true;
}
// A huge manifest package would be copied into every resolved name;
// once the byte budget is spent, later names keep their declared form.
const invalidDeclaredName =
declared.length === 0 ||
(!unresolvedName && !hasSupportedDexBinaryClassNameSyntax(declared));
const needsPackage = declared.startsWith(".") || !declared.includes(".");
const packageKnown =
!invalidDeclaredName &&
!unresolvedName &&
(!needsPackage ||
(packageName !== null &&
!packageHasInvalidWhitespace &&
!packageHasUnresolvedToken &&
!packageHasUnsupportedPackageSyntax));
const declaredBytes = Buffer.byteLength(declared);
const resolvedBytes =
needsPackage && packageKnown
? packageNameBytes +
(declared.startsWith(".") ? 0 : 1) +
declaredBytes
: declaredBytes;
const nameFits = packageKnown && resolvedBytes <= nameBudget;
const name = nameFits
? resolveAndroidComponentName(packageName, declared)
: declared;
if (invalidDeclaredName) invalidNames += 1;
if (!invalidDeclaredName) {
if (unresolvedName) unresolvedTokens += 1;
else if (!packageKnown) unresolvedPackages += 1;
else if (!nameFits) unresolvedNames += 1;
else nameBudget -= resolvedBytes;
}
if (component.nodeName === "activity" && nameFits) {
precedingActivityTargetNames.add(name);
precedingActivityTargetNames.add(declared);
if (
packageName !== null &&
!packageHasInvalidWhitespace &&
!packageHasUnresolvedToken &&
!packageHasUnsupportedPackageSyntax &&
name.length > packageName.length &&
name.startsWith(packageName) &&
name[packageName.length] === "."
) {
const relativeName = name.slice(packageName.length);
precedingActivityTargetNames.add(relativeName);
const bareName = relativeName.slice(1);
if (!bareName.includes("."))
precedingActivityTargetNames.add(bareName);
}
}
let authorities: readonly string[] | null = null;
if (component.nodeName === "provider") {
if (
androidAttribute(component, "readPermission") !== null ||
androidAttribute(component, "writePermission") !== null
)
providerPermissionOverridesUnknown += 1;
const declaredAuthorities = androidAttribute(component, "authorities");
if (declaredAuthorities !== null) {
const split = providerAuthorities(
declaredAuthorities,
authorityBudget,
);
malformedAuthoritySegments += split.malformed;
if (split.truncated) {
truncated = true;
capacityTruncated = true;
}
authorityBudget -= split.bytes;
if (authorityBudget < 0) authorityBudget = 0;
authorities = split.authorities;
}
}
const declaredPermission = androidAttribute(component, "permission");
const permissionValue =
declaredPermission === null
? component.nodeName === "activity-alias"
? null
: applicationPermission
: declaredPermission.length === 0
? null
: declaredPermission;
const permissionSource =
permissionValue === null
? "none"
: declaredPermission === null
? "application"
: "component";
let permission: string | null = permissionValue;
let effectivePermissionSource:
| "component"
| "application"
| "none"
| "unknown" = permissionSource;
if (permissionValue !== null) {
const permissionHasUnresolvedToken =
declaredPermission === null
? applicationPermissionHasUnresolvedToken
: containsUnresolvedToken(permissionValue);
if (permissionHasUnresolvedToken) {
permission = null;
effectivePermissionSource = "unknown";
unresolvedPermissionTokens += 1;
} else {
// Inherited application permission is the same string for every
// component. Measure it once at its owning boundary rather than
// rescanning a potentially large decoded value per inherited copy.
const permissionBytes =
declaredPermission === null
? (applicationPermissionBytes ?? 0)
: Buffer.byteLength(permissionValue);
if (permissionBytes > permissionBudget) {
permission = null;
effectivePermissionSource = "unknown";
truncated = true;
capacityTruncated = true;
permissionBudgetLimited = true;
} else permissionBudget -= permissionBytes;
}
}
const declaredExported = androidAttribute(component, "exported");
const targetActivity =
component.nodeName === "activity-alias"
? androidAttribute(component, "targetActivity")
: null;
if (component.nodeName === "activity-alias") {
if (targetActivity === null) {
unresolvedTargetActivities += 1;
} else {
const targetNeedsPackage =
targetActivity.startsWith(".") || !targetActivity.includes(".");
const targetUnresolved = containsUnresolvedToken(targetActivity);
const targetInvalid =
targetActivity.length === 0 ||
(!targetUnresolved &&
!hasSupportedDexBinaryClassNameSyntax(targetActivity));
const targetPackageKnown =
packageName !== null &&
!packageHasInvalidWhitespace &&
!packageHasUnresolvedToken &&
!packageHasUnsupportedPackageSyntax;
const targetResolved =
!targetInvalid &&
!targetUnresolved &&
(!targetNeedsPackage || targetPackageKnown);
if (
!targetResolved ||
!precedingActivityTargetNames.has(targetActivity)
)
unresolvedTargetActivities += 1;
}
}
const exportedLiteral =
declaredExported === "true"
? true
: declaredExported === "false"
? false
: null;
const exportedNonliteral =
declaredExported !== null && exportedLiteral === null;
const explicitExportRequired =
declaredExported === null &&
targetSdkLevel !== null &&
targetSdkLevel >= 31 &&
filters.length > 0 &&
(component.nodeName === "activity" ||
component.nodeName === "activity-alias" ||
component.nodeName === "service" ||
component.nodeName === "receiver");
const filteredExportUnknown =
declaredExported === null &&
targetSdkLevel === null &&
filters.length > 0 &&
(component.nodeName === "activity" ||
component.nodeName === "activity-alias" ||
component.nodeName === "service" ||
component.nodeName === "receiver");
// The default only matters when android:exported is absent.
if (
component.nodeName === "provider" &&
providerDefault === null &&
declaredExported === null
)
providerDefaultsUnknown += 1;
if (exportedNonliteral) nonliteralExported += 1;
if (explicitExportRequired) invalidExported += 1;
if (filteredExportUnknown) unknownFilteredExportDefaults += 1;
items.push({
kind: component.nodeName,
declared_name: declared,
name,
exported: exportedLiteral,
exported_default:
exportedNonliteral || explicitExportRequired || filteredExportUnknown
? null
: component.nodeName === "provider"
? providerDefault
: filters.length > 0,
permission,
permission_source: effectivePermissionSource,
intent_filter_count: filters.length,
actions: collected.actions,
categories: collected.categories,
foreground_service_type:
component.nodeName === "service"
? androidAttribute(component, "foregroundServiceType")
: null,
authorities,
target_activity: targetActivity,
});
}
}
if (!applicationSeen)
limitations.push(
"The manifest declares no <application> element, so no components were projected.",
);
// Dropped declarations mean the inventory is not the whole manifest.
if (unnamed > 0) {
limitations.push(
`${String(unnamed)} component declaration(s) without android:name are omitted from the inventory.`,
);
truncated = true;
}
if (invalidNames > 0) {
limitations.push(
`${String(invalidNames)} component name value(s) are empty or do not fit DEX binary class-name syntax; the raw declaration is retained without resolving it as an identity.`,
);
truncated = true;
}
if (packageHasInvalidWhitespace) {
limitations.push(
"The manifest package has surrounding whitespace; relative component names were not resolved against it.",
);
truncated = true;
}
if (packageHasUnsupportedPackageSyntax) {
limitations.push(
"The manifest package does not fit the supported Java package-name syntax; package-relative component names were not resolved against it.",
);
truncated = true;
}
if (providerPermissionOverridesUnknown > 0) {
limitations.push(
`${String(providerPermissionOverridesUnknown)} provider(s) declare android:readPermission or android:writePermission; operation-specific permission overrides are retained in the manifest text but are not projected.`,
);
truncated = true;
}
if (malformedAuthoritySegments > 0) {
limitations.push(
`${String(malformedAuthoritySegments)} provider authority segment(s) are empty or have surrounding whitespace; exact segments are retained and their registration is not established.`,
);
truncated = true;
}
if (providerDefaultsUnknown > 0)
limitations.push(
`${String(providerDefaultsUnknown)} provider(s) omit android:exported and the target SDK is unknown, so Android's export default for them is not established.`,
);
if (nonliteralExported > 0)
limitations.push(
`${String(nonliteralExported)} component declaration(s) set android:exported to a value other than the literals true or false (for example a resource reference), so their effective export state is unknown and the Android default does not apply.`,
);
if (unresolvedPackages > 0) {
limitations.push(
`${String(unresolvedPackages)} package-relative component name(s) remain unresolved because the manifest package is absent or contains a resource/placeholder token, or its syntax is unsupported; the declared name is retained.`,
);
truncated = true;
}
if (unresolvedTargetActivities > 0) {
limitations.push(
`${String(unresolvedTargetActivities)} activity-alias targetActivity value(s) are empty, malformed, unresolved against the manifest package, or do not match a preceding activity declaration; each exact declared value is retained.`,
);
truncated = true;
}
if (unresolvedTokens > 0) {
limitations.push(
`${String(unresolvedTokens)} component name(s) contain an unresolved resource or manifest placeholder token; the declared name is retained without claiming a resolved identity.`,
);
truncated = true;
}
if (permissionBudgetLimited)
limitations.push(
`Projected component permission values are capped at ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total bytes; omitted copies have permission_source "unknown".`,
);
if (unresolvedPermissionTokens > 0) {
limitations.push(
`${String(unresolvedPermissionTokens)} permission value(s) contain unresolved resource or manifest-placeholder tokens; their effective values are unknown.`,
);
truncated = true;
}
if (invalidExported > 0) {
limitations.push(
`${String(invalidExported)} component(s) with intent filters target API 31 or later and omit android:exported; Android requires an explicit value, so the inventory does not infer a default.`,
);
}
if (unknownFilteredExportDefaults > 0)
limitations.push(
`${String(unknownFilteredExportDefaults)} filtered component(s) omit android:exported and the target SDK is unknown, so the applicable export rule is not established.`,
);
if (unresolvedNames > 0) {
limitations.push(
`${String(unresolvedNames)} component name(s) keep their declared form because the resolved-name byte budget (${String(ANDROID_MANIFEST_LIMITS.resolvedNameBytes)}) was exhausted.`,
);
truncated = true;
}
if (capacityTruncated)
limitations.push(
`The component inventory reached a capacity limit: ${String(ANDROID_MANIFEST_LIMITS.components)} declarations, ${String(ANDROID_MANIFEST_LIMITS.intentFilters)} filters or ${String(ANDROID_MANIFEST_LIMITS.intentValues)} values per component, ${String(ANDROID_MANIFEST_LIMITS.authorities)} authorities per provider, ${String(ANDROID_MANIFEST_LIMITS.authorityBytes)} total authority bytes, or ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total permission bytes.`,
);
return {
status: truncated ? "partial" : "complete",
items,
limitations,
};
};
+7
View File
@@ -1,6 +1,7 @@
import type { z } from "zod";
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
import { androidResultSchemas } from "../domain/android/androidAnalysis.js";
import { parseAndroidManifestComponents } from "./AndroidManifestComponents.js";
import {
AnalysisInputError,
AnalysisOutputError,
@@ -127,6 +128,12 @@ const inspectPackage = async (
target_sdk: app.target_sdk || null,
permissions: app.permissions,
manifest,
components: parseAndroidManifestComponents(
manifest.text,
manifest.status,
app.target_sdk || null,
app.min_sdk || null,
),
class_count: loaded.class_count,
resource_count: loaded.resource_count,
signature_verification: "not_performed",
@@ -15,7 +15,7 @@ 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. 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.',
'Inspect a local APK\'s package, SDK and permission declarations, decoded manifest and class/resource counts, with a bounded static component projection. 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: androidPackageInspectionInputSchema,
outputSchema: evidenceResultOf(
+43
View File
@@ -96,6 +96,40 @@ const textCoverage = z.strictObject({
text: z.string(),
reported_total_bytes: z.int().nonnegative().nullable(),
});
const manifestComponent = z.strictObject({
kind: z.enum([
"activity",
"activity-alias",
"service",
"receiver",
"provider",
]),
declared_name: z.string(),
name: z.string(),
exported: z.boolean().nullable(),
exported_default: z.boolean().nullable(),
permission: z.string().nullable(),
permission_source: z.enum(["component", "application", "none", "unknown"]),
intent_filter_count: z.number().int().nonnegative(),
actions: z.array(z.string()),
categories: z.array(z.string()),
foreground_service_type: z.string().nullable(),
authorities: z.array(z.string()).nullable(),
target_activity: z.string().nullable(),
});
/**
* Static component projection of the decoded manifest. The export default is
* Android's documented rule for an absent android:exported when one applies;
* null also means the default is unknown or an explicit declaration is
* required. This is not a merged-manifest or runtime observation.
*/
const manifestComponents = z.strictObject({
status: z.enum(["complete", "partial", "unavailable"]),
items: z.array(manifestComponent),
limitations: z.array(z.string()),
});
/** A DEX prototype; hex parentheses also keep the advertised class valid in v mode. */
export const dexDescriptorSchema = z
.string()
@@ -131,6 +165,15 @@ export const androidResultSchemas = {
target_sdk: z.string().nullable(),
permissions: z.array(z.string()),
manifest: textCoverage,
// Evidence retained before the component projection existed re-parses
// with an explicit unavailable inventory; current results always carry it.
components: manifestComponents.default(() => ({
status: "unavailable" as const,
items: [],
limitations: [
"This Evidence predates the manifest component projection; run inspect_android_package again to obtain it.",
],
})),
class_count: z.int().nonnegative(),
resource_count: z.int().nonnegative(),
signature_verification: z.literal("not_performed"),
@@ -44,6 +44,88 @@ const engine = {
heap_limit_mib: null,
};
it.each([
["", "", true, true, null],
["16", "", true, true, null],
["17", "", false, true, null],
["33", "", false, null, null],
["16", "17", false, true, null],
["Preview", "", null, null, null],
["1", "Preview", null, null, null],
["", "", null, null, 'android:targetSdkVersion=""'],
["", "", null, null, 'android:minSdkVersion=""'],
[
"16",
"",
null,
null,
'android:minSdkVersion="16" android:targetSdkVersion=""',
],
] as const)(
"derives export rules from minimum SDK %s and declared target %s",
async (minimum, target, providerDefault, activityDefault, sourceSdk) => {
const sdk =
sourceSdk ??
[
minimum === "" ? "" : `android:minSdkVersion="${minimum}"`,
target === "" ? "" : `android:targetSdkVersion="${target}"`,
]
.filter(Boolean)
.join(" ");
const manifest = `<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">${sdk === "" ? "" : `<uses-sdk ${sdk}/>`}<application><provider android:name=".Data"/><activity android:name=".Filtered"><intent-filter><action android:name="example.OPEN"/></intent-filter></activity></application></manifest>`;
const tools: JadxToolPort = {
json: (name) => {
expect(name).toBe("get_app_info");
return Promise.resolve({
package: "com.example",
version_name: "",
version_code: "",
min_sdk: minimum,
target_sdk: target,
permissions: [],
});
},
text: (name) => {
expect(name).toBe("get_android_manifest");
return Promise.resolve(manifest);
},
listClasses: () => Promise.reject(new Error("Unexpected class query")),
};
const report = androidResultSchemas.inspect_android_package.parse(
await analyzeJadxRequest(
tools,
{
operation: "inspect_android_package",
input: { path: "/fixture.apk" },
},
{
state: "LOADED",
apk_path: "/snapshot.apk",
class_count: 0,
resource_count: 1,
threads: 1,
resources: "full",
},
engine,
),
);
expect(report.target_sdk).toBe(target || null);
expect(report.min_sdk).toBe(minimum || null);
expect(report.components.status).toBe("complete");
expect(report.components.items[0]?.exported_default).toBe(providerDefault);
expect(report.components.items[1]?.exported_default).toBe(activityDefault);
if (sourceSdk !== null || minimum === "Preview" || target === "Preview")
expect(report.components.limitations.join(" ")).toContain(
"target SDK is unknown",
);
else if (minimum === "33" && target === "")
expect(report.components.limitations.join(" ")).toContain(
"requires an explicit value",
);
else expect(report.components.limitations).toEqual([]);
},
);
it("pages filtered matches independently of the examined inventory and preserves cross-page duplicates", async () => {
const names = Array.from({ length: 5000 }, (_, i) => `fixture.Target${i}`);
names[0] = "fixture.TargetDuplicate";
File diff suppressed because it is too large Load Diff