Harbor Under Audit: Securing an Android App When an Agent Holds the Pen
A layered playbook for Kotlin teams: audit with agent skills, ship against Play policy, defend at runtime, and collect less data in the first place. Harbor Under Audit: Securing an Android App When an Agent Holds the P
A layered playbook for Kotlin teams: audit with agent skills, ship against Play policy, defend at runtime, and collect less data in the first place.

Harbor Under Audit: Securing an Android App When an Agent Holds the Pen
Coding agents now write a meaningful share of Android pull requests. Security review has not kept pace, and the gap is wider than most teams admit. A generic agent can read your Kotlin quickly, but it does not know that your project ships three dynamic feature modules, that a shared KMP module hands strings to a JNI parser, or that your minSdk rules out half of the "obvious" fixes it proposes.
This post follows a fictional bill-splitting wallet called Harbor through four defensive moments: writing, shipping, running, and adapting. Every layer has a failure mode we have either seen in production or can reproduce in an afternoon. The structure is inspired by the Android Developers talk App security in the agentic era; the app, code and failure scenarios here are original.
1. Why a general agent misses what matters
Ask a general-purpose agent to “audit this repo” and you will usually get a long, confident report. Three structural problems explain why that report is less useful than it looks.
It does not model your build. Harbor has :app, :feature-receipts (a dynamic feature) and :shared (KMP). Components declared in the dynamic feature's manifest ship in a separate split of the app bundle and never appear in the base module's manifest. An agent that greps AndroidManifest.xml in :app will report a clean bill of health while the exposed surface sits one module over. Audit each module's manifest, and the merged manifest of every variant you actually publish.
It loses taint at language seams. A receipt filename travels from a Kotlin Uri, through a KMP expect/actual function, into a C++ thumbnailer via JNI. Each hop looks harmless in isolation. The path traversal only exists across all three.
It optimizes for coverage, not actionability. Left alone, the agent brute-forces every code path and returns hundreds of warnings that are individually defensible and collectively useless. The predictable human reaction is to mute the checks. That is the real security regression: a team that has silenced lint has no early-warning system left.
Failure mode: the suppression ratchet. Harbor’s lint baseline grew from 12 to 340 entries in one quarter after an agent-generated report. Nobody reviewed the baseline diff because it was “just a baseline.” Two of the entries hid an exported component. Treat a growing baseline as a metric your CI reports, and require a written reason for each new suppression.
2. Android security skills: heuristics instead of guesswork
A skill packages how a security researcher actually works, as structured guidance an agent loads on demand. Rather than “find vulnerabilities,” the agent gets a procedure: enumerate the attack surface, decide whether an untrusted app can reach it, and propose a fix that fits the project.
I am deliberately not quoting install commands here, because the tooling is evolving quickly. Use the current Android developer documentation for setup; the workflow below is what matters.
2.1 Intent surface
Two classic problems dominate: exported components that accept more than they should, and intent redirection, where your app launches an Intent that an attacker built.
Here is Harbor’s original deep-link trampoline. It looks convenient; it is a privilege escalation.
// AndroidManifest: exported="true" so links from email can open it
class LinkTrampolineActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val next = IntentCompat.getParcelableExtra(intent, "next", Intent::class.java)
next?.let { startActivity(it) } // runs with Harbor's identity and permissions
finish()
}
}
Any app on the device can send a next extra pointing at a non-exported Harbor screen, and Harbor will happily launch it. The fix is to stop forwarding foreign intents entirely and rebuild one from validated pieces:
class LinkTrampolineActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val destination = intent.data?.let(::parseDestination)
if (destination != null) {
startActivity(destination.toInternalIntent(this))
}
finish()
}
private fun parseDestination(uri: Uri): Destination? = when {
uri.host != "harbor.example" -> null
uri.pathSegments.firstOrNull() == "receipt" ->
uri.lastPathSegment?.toLongOrNull()?.let(Destination::Receipt)
else -> null
}
}
sealed interface Destination {
data class Receipt(val id: Long) : Destination
}
private fun Destination.toInternalIntent(context: Context): Intent = when (this) {
is Destination.Receipt -> Intent(context, ReceiptActivity::class.java)
.putExtra(ReceiptActivity.EXTRA_ID, id)
}
The sealed type is the security boundary: only destinations you enumerated can ever become an intent. Pending intents get the same treatment. Create them with an explicit component and PendingIntent.FLAG_IMMUTABLE, so a recipient cannot rewrite the action or extras you meant to be fixed.
2.2 Permissions that are enforced, not just declared
Declaring a permission in the manifest and enforcing it in code are two different things. A skill checks that the one implies the other, and flags over-broad grants.
<!-- Before: with grantUriPermissions="true", ANY URI in the provider can be granted -->
<provider
android:name=".ReceiptProvider"
android:authorities="com.example.harbor.receipts"
android:exported="false"
android:grantUriPermissions="true" />
<!-- After: the attribute is off, so only the listed subtree can ever be granted -->
<provider
android:name=".ReceiptProvider"
android:authorities="com.example.harbor.receipts"
android:exported="false"
android:grantUriPermissions="false">
<grant-uri-permission android:pathPrefix="/shared/" />
</provider>
The attribute is the master switch. When it is true, every URI the provider serves is grantable and child filters add nothing; when it is false, only the subsets named by <grant-uri-permission> elements can be granted. Narrow the pattern, then grant per-URI with FLAG_GRANT_READ_URI_PERMISSION when you actually share.
Failure mode: the decorative filter. A very common review miss is adding a tight
<grant-uri-permission>child while leavinggrantUriPermissions="true"on the provider. The diff looks like hardening and changes nothing. Have your agent, and your reviewer, check the attribute first.
The same audit pass should retire broad media permissions. If Harbor only needs the user to attach one image, the system Photo Picker needs no storage permission at all:
@Composable
fun AttachReceiptButton(onPicked: (Uri) -> Unit) {
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri -> uri?.let(onPicked) }
Button(onClick = {
picker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}) { Text("Attach receipt") }
}
2.3 Fixes that compile
A useful skill reads the project before suggesting anything. It checks minSdk, confirms the AndroidX artifact is on the classpath, and returns a diff that builds. A fix that needs API 33 in an app that supports API 26 is not a fix; it is a new bug.
Failure mode: the unreachable finding. An agent flagged an exported receiver in a debug-only source set and “fixed” it by adding a permission that production builds then inherited through a shared manifest. Always ask the triage question from the diagram: can an untrusted app actually reach this path in a release build?
Human review is not optional. Read every diff, run instrumentation tests that exercise the changed component, and keep the agent’s reasoning in the PR description so reviewers can challenge it.
3. Shipping: let static analysis argue with your Play Console
Many rejected releases are not caused by malicious code. They are caused by drift between what the app does and what the listing says it does.
A Play policy compliance skill compares your code against your Play Console declarations before you submit. The typical catch is a data-safety mismatch: Harbor adds an analytics SDK, the SDK reads an advertising identifier, and the Data Safety form still says no identifiers are collected.
Make this a CI step rather than a pre-release scramble:
- Run the policy skill on every release branch, not only on the release candidate.
- Diff the dependency graph between releases. Most undeclared collection arrives transitively.
- Store the declared answers in the repository so changes to them go through code review.
Failure mode: the transitive SDK. Harbor never touched location. A payment-fraud SDK upgrade did, and a store review caught it eight hours before launch. Pinning the SDK in a version catalog and reviewing its manifest diff would have caught it on the day of the bump.
4. Runtime: Play Integrity and the server-side decision
Static review cannot see a repackaged APK, a rooted emulator farm, or a leaked client. At runtime, the Play Integrity API answers three questions: is the app binary the one Google Play distributed, is it running on a genuine certified device, and is the account licensed for the app. A Play dashboard view then aggregates these signals so you can monitor abuse trends across integrity, distribution and monetization.
The key design rule: the client requests; the server decides. A token decoded on the device is a token an attacker can forge.
4.1 A client gate that warms up early
The standard request flow has a prepare step that carries most of the latency. Do it before the user reaches a sensitive screen, then request tokens bound to the action they are about to take.
class IntegrityGate(
appContext: Context,
private val cloudProjectNumber: Long,
private val scope: CoroutineScope,
) {
private val manager = IntegrityManagerFactory.createStandard(appContext)
private val lock = Mutex()
private var provider: StandardIntegrityManager.StandardIntegrityTokenProvider? = null
// Cache the successful provider, never the attempt: failures are retried next time.
private suspend fun provider() = lock.withLock {
provider ?: manager.prepareIntegrityToken(
StandardIntegrityManager.PrepareIntegrityTokenRequest.builder()
.setCloudProjectNumber(cloudProjectNumber)
.build()
).await().also { provider = it }
}
/** Call when the user opens the wallet tab, long before they tap "Send". */
fun warmUp() {
scope.launch { runCatching { provider() } } // best effort; tokenFor() retries on demand
}
suspend fun tokenFor(actionPayload: ByteArray): String {
val requestHash = MessageDigest.getInstance("SHA-256")
.digest(actionPayload)
.joinToString("") { "%02x".format(it) }
return try {
provider().request(
StandardIntegrityManager.StandardIntegrityTokenRequest.builder()
.setRequestHash(requestHash)
.build()
).await().token()
} catch (e: Exception) {
// Inspect the error code whichever way the failure surfaced (directly or as a cause).
val integrityError = e as? StandardIntegrityException
?: e.cause as? StandardIntegrityException
// Only discard a provider the API says is no longer valid; a flaky network is not that.
if (integrityError?.errorCode == StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID) {
lock.withLock { provider = null }
}
throw e
}
}
}
Three details here are easy to miss. The requestHash ties the token to the exact transfer payload, which defeats replay of a token captured from a different action. The gate caches the provider, not the in-flight attempt, because a cached Deferred remembers its failure and would keep rethrowing it after one transient network error. And the reset is limited to the "provider invalid" error, so an ordinary request timeout does not throw away a healthy warm-up. Because warmUp() swallows its own failure inside launch, a bad prepare call cannot cancel the sibling jobs of whichever scope you pass in. Confirm the error-code name against the current Play Integrity reference for your library version.
4.2 A server decision with a graceful middle
On your backend, decode the token through Google’s servers, then check the request hash, timestamp freshness and package name before looking at any verdict. After that, map verdicts to a three-way decision rather than a binary block.
data class Verdicts(
val hashMatches: Boolean,
val ageMillis: Long,
val appRecognized: Boolean,
val deviceMeetsIntegrity: Boolean,
val licensed: Boolean,
)
sealed interface Decision {
data object Allow : Decision
data class StepUp(val reason: String) : Decision
data class Deny(val reason: String) : Decision
}
fun decide(v: Verdicts, riskyAction: Boolean): Decision = when {
!v.hashMatches || v.ageMillis > 2 * 60_000 -> Decision.Deny("stale or mismatched token")
!v.appRecognized -> Decision.Deny("unrecognized binary")
!v.deviceMeetsIntegrity && riskyAction -> Decision.StepUp("unverified device on risky action")
!v.deviceMeetsIntegrity -> Decision.Allow
!v.licensed -> Decision.StepUp("unlicensed account")
else -> Decision.Allow
}
Failure mode: the hard block. Harbor’s first version denied every non-compliant device. Support tickets spiked from legitimate users on older, uncertified hardware, and the team quietly disabled the check. A step-up path (biometric, a payout hold, a lower limit) preserves the signal without punishing honest users.
Failure mode: verdict-only trust. Teams often validate the verdict and forget the request hash. Without it, a valid token from one session can be replayed against a different action. Check the hash first.
5. Collect less: privacy-preserving APIs
The best-protected data is data you never held. Three platform features make that practical.
5.1 One-time codes without reading the inbox
Harbor used to request READ_SMS to auto-fill verification codes. That permission exposes every message on the device, which is far more than a six-digit code deserves. Two APIs replace it, and your SMS format decides which one you can use.
SMS Retriever is silent, but it requires that you control the message: it must be short, and it must end with an 11-character hash derived from your app’s signing certificate.
fun smsCodes(context: Context): Flow<String> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context, intent: Intent) {
val extras = intent.extras ?: return
val status = BundleCompat.getParcelable(extras, SmsRetriever.EXTRA_STATUS, Status::class.java)
when (status?.statusCode) {
CommonStatusCodes.SUCCESS -> {
val body = extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE).orEmpty()
Regex("""\b\d{6}\b""").find(body)?.value?.let { trySend(it) }
}
CommonStatusCodes.TIMEOUT -> close(TimeoutException("No SMS within the window"))
}
}
}
ContextCompat.registerReceiver(
context, receiver, IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION),
SmsRetriever.SEND_PERMISSION, null, ContextCompat.RECEIVER_EXPORTED,
)
SmsRetriever.getClient(context).startSmsRetriever()
.addOnFailureListener { close(it) }
awaitClose { context.unregisterReceiver(receiver) }
}
Note the receiver is exported on purpose, because Google Play services is the sender, and it is protected by the SEND_PERMISSION so arbitrary apps cannot spoof a message to it. Register before you start the retriever so you never miss an early delivery.
SMS User Consent is the fallback when you do not control the template. The system shows the user a single message and asks permission to share it with your app, optionally filtered by sender.
private val consentLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
val body = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE).orEmpty()
viewModel.onSmsBody(body)
}
}
// In the receiver, when the status code is SUCCESS:
BundleCompat.getParcelable(extras, SmsRetriever.EXTRA_CONSENT_INTENT, Intent::class.java)
?.let { consentLauncher.launch(it) }
Failure mode: the wrong hash. Verification codes worked perfectly in debug builds and silently failed in production. The hash is computed from the signing certificate, and Play App Signing re-signs your release. Compute the hash for the certificate users actually receive, and keep separate server templates for each build variant.
Failure mode: template drift. A copy edit pushed the SMS past its length limit and removed the hash line. Retriever never fired. Add a server-side test that asserts the template’s length and suffix.
5.2 A button instead of a standing location permission
Many features, such as “find a split-friendly cafe nearby,” need precise location once. Android 17 introduces a system-styled location button: the user taps it, grants access for that session, and your app does not need a persistent grant. The Jetpack component falls back to the traditional permission prompt on Android 16 and below. At announcement it was in beta, so check the current release notes for the artifact and its API before you integrate; I am not pinning a class name here.
What you can design today is the code around the button, so swapping it in is a small change:
class NearbyViewModel(private val location: FusedLocationProviderClient) : ViewModel() {
private val _state = MutableStateFlow<NearbyState>(NearbyState.Idle)
val state = _state.asStateFlow()
/** Call from the location button's granted callback. */
@SuppressLint("MissingPermission") // the button flow is what grants access
fun onLocationGranted() {
viewModelScope.launch {
_state.value = NearbyState.Loading
_state.value = runCatching {
val token = CancellationTokenSource()
currentCoroutineContext()[Job]?.invokeOnCompletion { token.cancel() }
location.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, token.token).await()
}.fold(
onSuccess = { it?.let(NearbyState::Found) ?: NearbyState.Unavailable },
onFailure = { NearbyState.Unavailable },
)
}
}
/** Call when the user leaves the screen, not when the ViewModel dies. */
fun onSessionEnded() { _state.value = NearbyState.Idle }
}
Wire onSessionEnded() to the screen's ON_STOP event (for example with LifecycleEventEffect(Lifecycle.Event.ON_STOP) in Compose). A ViewModel can outlive its screen across configuration changes, so clearing in onCleared() is too late and does nothing useful: by then nobody is collecting.
Failure mode: quiet retention. One-time access is easy to defeat by caching. If Harbor stores the coordinate in a database “for faster next time,” the privacy gain is gone and the Data Safety declaration is wrong. Keep the fix in memory, clear it when the screen stops, and never persist it.
5.3 Advanced Protection as a risk signal
Android 16 added Advanced Protection Mode for users who face elevated threats. Apps with security-sensitive flows can read the state and tighten their own behavior, for example by requiring a biometric on every transfer. It needs the QUERY_ADVANCED_PROTECTION_MODE permission and an SDK guard.
<uses-permission android:name="android.permission.QUERY_ADVANCED_PROTECTION_MODE" />
fun advancedProtection(context: Context): Flow<Boolean> {
if (Build.VERSION.SDK_INT < 36) return flowOf(false)
return callbackFlow {
val manager = context.getSystemService(AdvancedProtectionManager::class.java)
if (manager == null) { trySend(false); close(); return@callbackFlow }
trySend(manager.isAdvancedProtectionEnabled) // explicit initial state, never rely on timing
val callback = AdvancedProtectionManager.Callback { enabled -> trySend(enabled) }
manager.registerAdvancedProtectionCallback(ContextCompat.getMainExecutor(context), callback)
awaitClose { manager.unregisterAdvancedProtectionCallback(callback) }
}.distinctUntilChanged()
}
data class TransferPolicy(val requireBiometric: Boolean, val dailyLimitCents: Long)
fun policyFor(advancedProtection: Boolean) =
if (advancedProtection) TransferPolicy(requireBiometric = true, dailyLimitCents = 50_000)
else TransferPolicy(requireBiometric = false, dailyLimitCents = 250_000)
The API reference says the callback also fires once on registration, while the integration guide recommends an on-demand status query at initialization. The two sources read differently, so the flow does both: it emits the current state first, registers for changes, and lets distinctUntilChanged() absorb the duplicate. A collector that calls .first() can then never hang waiting for a toggle.
Failure mode: the UI-only policy. Harbor enforced the stricter limit in the Compose layer and nowhere else. A scripted client skipped it. Mirror the rule on the server: the client can report the signal, but the backend must still demand the step-up.
Failure mode: punishing the protected. A user who enables the mode may also be unable to use biometrics. Provide a fallback factor (a hardware key or a passphrase), or you will lock out precisely the people the feature exists to protect.
6. A Monday-morning checklist
- Add a CI metric for the size of the lint baseline, and fail a PR that grows it without a written reason.
- Run an intent and permission skill on every module, including dynamic features, and have the agent state which entry points are reachable from outside the app.
- Replace each forwarded foreign
Intentwith a sealedDestinationthat you rebuild yourself. - Set
grantUriPermissions="false"wherever you rely ongrant-uri-permissionfilters, narrow every wildcard, and swap broad storage access for the Photo Picker. - Run the Play policy skill on release branches and diff transitive SDK manifests.
- Warm Play Integrity early, bind tokens to a request hash, and decide on the server with a step-up path.
- Retire
READ_SMSin favor of SMS Retriever or User Consent, and test the template per signing variant. - Keep one-time location in memory only, and enforce Advanced Protection policy on the server as well as the client.
🔚 Closing thought
An agent is a fast, tireless junior reviewer with no context about your product. Skills give it that context, Play tooling checks your claims against reality, and platform APIs shrink what there is to protect. None of these layers replaces the others, and none replaces a human reading the diff. Build all four, and keep the person who merges the change accountable for it.
📱 New to Jetpack Compose? Start Here
If you’re coming from the classic Android View system — XML layouts, findViewById, RecyclerView adapters — and want a clear, no-jargon path into Jetpack Compose, I recommend "Learning Jetpack Compose: Your Practical Guide from the Android View System to Modern Declarative UI." Every chapter starts from something you already know and shows its Compose equivalent side by side, building up to a full capstone: a real Notes app. Once you're comfortable and want to go deeper — the Composition tree, recomposition internals, the runtime itself — "Mastering Jetpack Compose Internals" is the natural next step.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.





