The other thing I maintain besides AgentGuard is a small Android app: a thought-record notebook built around David D. Burns' three-column technique. On paper the two have nothing to do with each other — one is a Python proxy for AI agent tool calls, the other is a notes app with sliders in it. They came out of the same habit of mind, though, and the notebook is the cleaner illustration of it, because it is small enough that the whole security design fits in one sentence: the app is not allowed to reach the network, so nothing it holds can be sent anywhere.
This is about how that sentence is actually enforced, what it cost me, and the one place where it is not completely true.
A capability you never take beats a policy you promise to keep
"This app doesn't upload your data" is a promise. Promises are worth something when you can inspect the source, and worth very little otherwise — and they degrade quietly: a crash reporter added in version 1.6 to debug a bug report, an analytics SDK pulled in as a transitive dependency of an unrelated library, a "check for updates" ping that seemed harmless on the day it was written. None of those require anyone to act in bad faith. They only require that the ability to send bytes somewhere still be sitting there, unused, waiting for a plausible reason.
Android has a much better mechanism available, and it costs one line of nothing: don't declare android.permission.INTERNET. The app declares two permissions in total, both for the optional lock:
aapt dump badginguses-permission: name='android.permission.USE_BIOMETRIC'
uses-permission: name='android.permission.USE_FINGERPRINT'
Without INTERNET, socket creation fails at the kernel level — the app's UID simply is not in the group that is allowed to open one. It does not matter whether the calling code is mine, a library's, or something added by a build plugin I did not read carefully. The check does not depend on my discipline, and it does not weaken as the codebase grows. It is also verifiable by a stranger in about four seconds, which is the property I actually care about: a user who does not trust me does not have to.
That is the same shape as AgentGuard's core argument. There, the point was that an agent's tool access should be constrained at the protocol boundary rather than by asking the model nicely in a system prompt, because a boundary the program cannot see past is worth more than an instruction it can be talked out of. Here the boundary is the permission manifest instead of a proxy, and the program being constrained is my own code instead of somebody's agent. The reasoning is identical: decide what it is allowed to reach, then put the enforcement where it cannot be renegotiated.
What that forecloses
I want to be honest that this is a real cost, not a free win, because "just don't request INTERNET" is easy to say when you are not the one giving up the features:
- No sync and no backup service of my own. If you get a new phone, you export a file and import it. There is no account to log into because there is no server to log into.
- No crash reporting. When the app crashes for you, I do not find out. Bugs reach me only if you email me. For an app with a handful of screens this is survivable; for anything with real complexity it would not be.
- No remote config, no A/B tests, no usage analytics. I have no idea which features are used. Every product decision is made from first principles and my own use, which is a worse process than data, and a tradeoff I would not accept on a product with users to answer to.
- Updates are manual. Outside Google Play, the APK on this site does not update itself.
The reason those are acceptable here is that the app's value is entirely in the writing you put into it, and the writing is exactly the thing that should not move. A notes app that ships your notes to a server in return for convenience is making that trade on your behalf, usually without saying so on the download page.
The escape hatch is a file, on purpose
Data still has to be able to get out — otherwise a lost phone means lost notes, and the design becomes hostile rather than private. The app's answer is an explicit JSON export and import: you press export, you get a file, you decide what happens to it. The import side takes the same shape, which incidentally makes bulk entry possible, and the About screen prints the expected format so you can hand-author one.
The design property I wanted is that every path out of the app requires a human action at the moment it happens. An export is a button press. A share is a share-sheet tap. Neither can happen while the phone is in your pocket. Sync, by contrast, is a path that runs on its own schedule forever after a single decision made once during onboarding — which is what makes it convenient, and what makes it the wrong default for this particular kind of content.
The hole I left in it
There is one path out I did not write, and it is still there: the app's backup rules include its database, so if you have Android's own backup switched on, a copy of your entries goes to your Google account with everything else on the phone.
res/xml/data_extraction_rules.xml, as shipped<data-extraction-rules>
<cloud-backup>
<include domain="database" path="." />
</cloud-backup>
</data-extraction-rules>
Android's backup is end-to-end encrypted with the device credential on Android 9 and later, so this is not the same as shipping plaintext to a server I control. But it is off-device storage, and it happens without a per-event human action, which is precisely the property I just claimed the design does not have. Writing this piece is what made me look at the file again and notice the contradiction.
The honest accounting: this was the default the project template gave me, kept because losing a phone should not mean losing months of records, and never re-examined against the rule the rest of the app follows. The consistent version is to exclude the database from cloud backup and let the explicit export be the only recovery story — which is a slightly worse day for someone who drops their phone in a river, and a better match for what the page promises. That is the change I would make next, and until it ships, the app page says the exception is there rather than leaving it for someone to find.
Leaks on the screen, not just on the disk
One last piece, because storage is only one of the surfaces. The optional app lock uses fingerprint, face recognition or the device password; while the app is locked, its preview in the recent-apps switcher is hidden and screenshots are blocked.
That matters because the realistic threat model for this app is not a remote attacker at all. It is someone picking up an unlocked phone, or a screenshot taken for an unrelated reason that catches a thought record behind it, or the recent-apps carousel showing the last screen you were on to whoever is standing next to you. The remote threat is handled structurally by the missing permission; what is left is the local one, and it is handled at the surfaces where local leaks actually happen.
The general version
Both of the things I maintain are, underneath, the same claim: a program should be able to reach exactly what its job requires and nothing else, and that limit should live somewhere the program cannot argue with — a proxy it must pass through, or a permission it was never granted. The notebook is the version of that argument small enough to verify in one command. AgentGuard is the version where the program being constrained is an AI agent whose behavior nobody, including the people who trained it, can fully predict.
The part I would emphasize to anyone applying this: the mechanism is only worth what it forecloses. Removing a capability you were never going to use is theatre. Removing one you genuinely wanted — sync, crash reports, usage data — is the whole point, and it is why the list of costs above is longer than the list of benefits.