You build one local API over your notes, contacts and calendar. Five endpoints, each behind its own scope: read, list, search, count, aggregate. You hand an agent a token with notes:search and nothing else. The body is withheld on every endpoint that returns a document.
That token recovers all 14 private bodies, character for character, in a mean of 751 yes/no questions each.
👉 PUBLIC, MIT, dependencies = []: https://github.com/dev48v/personal-api
👉 Live measurement in your browser: https://dev48v.infy.uk/agentlab/vol4-04-personal-api.html
python -m personal_api # what each scope is actually worth
python -m personal_api --oracles # the five oracles, side by side
python -m personal_api --demo # watch one note come back out
What each scope promises, and what it delivers
| token scope | can it read a body? | bodies recovered anyway |
|---|---|---|
| read everything | yes | 14/14 |
| titles only | no | 0/14 |
| titles + search | no | 14/14 exact |
| titles + count | no | 3/14 exact, 14 whole bodies |
| titles + aggregate (k floor) | no | 0/14 |
| search only | no | 14/14 exact |
The second column is what the scope model promises. The third is the measurement.
The only preset that holds is titles only — and it holds because it has no query endpoint at all. A personal API you cannot ask questions of is not the thing anybody was trying to build.
The attack is not clever
known = ""
while len(known) < MAX:
for ch in ALPHABET: # 37 symbols
if oracle(known + ch, "prefix"):
known += ch
break
Ask whether the body starts with a, then b, then c. When one says yes, keep it and ask for the next character. At most 37 questions per character, no backtracking. It is what an agent with a token and a while loop does on its own, and it is linear in the length.
The corpus is invented and written out in one short file before any number is computed, so you can check the counts rather than take them. test_alphabet_covers_corpus holds the alphabet to the corpus — a body containing a symbol outside it would stall the attack and understate every figure here. (That bug was real: the first version omitted digits 1–9 and reported 1/14 instead of 14/14.)
The endpoints ranked by what they are worth to an attacker
| oracle | exact | any whole body | fragment recovered | mean calls |
|---|---|---|---|---|
| search, prefix (autocomplete) | 14/14 | 14/14 | 100.0% | 751 |
| search, contains (a search box) | 1/14 | 1/14 | 53.5% | 401 |
| count, prefix | 3/14 | 14/14 | 23.1% | 706 |
| aggregate, k=2 | 0/14 | 0/14 | 2.8% | 68 |
| aggregate, k=3 | 0/14 | 0/14 | 0.7% | 43 |
An autocomplete is worth far more than a search box. A prefix oracle has an anchor — the start of the string — so extension never backtracks and recovery is exact. A contains oracle has to find an anchor first and can only extend rightward, so it returns a fragment: 1 exact body in 14, but 53.5% of each body on average, contiguous. Half of a note about a bank account is not half a secret.
Counting is a read too, just aimed less well. count > 0 is the same boolean, so the same loop runs — but the count is over the whole collection, so greedy extension follows whichever record branches first. It recovers the targeted body 3 times in 14 and a complete body of some record all 14 times. The scope did not stop the leak. It randomised the victim.
The one defence that works, and why that is a caveat
With a k-anonymity floor the channel closes: 0 exact, 0 whole bodies, and 2.8% of a body at k=2 — about one character. That is a real defence and it is the recommendation this build actually supports.
But it works because every body in this corpus is unique, so any prefix long enough to be informative matches fewer than k records and gets suppressed. A floor does not protect a value that k people share. The suite says so out loud in test_the_k_floor_works_because_the_secrets_are_unique, so the caveat cannot quietly come unstuck from the claim.
Rate limiting is arithmetic
| limit | one body | all 14 |
|---|---|---|
| 60 req/min | 12.5 min | 2 h 55 m |
| 600 req/min | 1.3 min | 17.5 min |
| 6000 req/min | 7.5 s | 1.8 min |
At the tightest limit anyone actually ships, the whole corpus comes out inside one night. Rate limiting converts "instant" into "overnight", which is not a defence against something that runs overnight.
The control that would help is the one I did not model: 968 near-identical prefix queries against one field is a shape, and an audit log can alarm on a shape. That is the honest recommendation out of this build, and it is not a scope.
What this is not
Small invented corpus; the query counts scale with its strings. In-process API — no HTTP layer, no token expiry, no audit log. Nothing models partial field exposure, redaction, differential privacy with real noise, or an LLM in the loop deciding what to answer. Both attacks are the naive ones on purpose: they establish a floor on what is recoverable, not a ceiling.
25 pytest, most of them controls — that read is genuinely denied, that list genuinely withholds the body, that titles only genuinely recovers nothing, and that the attack never once calls a denied endpoint (the denied-call counter is asserted to stay at zero, so it is a reconstruction and not a read wearing a hat).
The claim is narrow. An endpoint that answers questions about a field is an endpoint that returns the field, and a scope model that separates them is describing an intent rather than a boundary.
Top comments (0)