Found and fixed on worktree-store-scan-on-demand (6fba2b3), before any deployment has ever had scanning switched on. Filing it because the shape of the mistake is worth more than the fix.
The property
Every verdict object-storage scanning reports rests on one asymmetry: the console can write the carlos-scan tag and the app cannot. If an app can tag its own object, it can mark its own malware clean and the platform will report it clean.
storePolicyJSON said so in capitals. AGENTS.md carries it as a standing rule: s3:PutObjectTagging must never be added to the app user's Allow statement. A purpose-built test helper, assertNoPutObjectTagging, existed to enforce it and was called from every policy test.
All of that was true, and none of it was sufficient.
There are two ways to put a tag on an object
Leaving the permission out of the Allow closes one — PutObjectTagging, the explicit call.
The other is the x-amz-tagging header on PutObject itself, which attaches tags during the upload and never names the tagging action at all. That is why nothing caught it: assertNoPutObjectTagging searched the policy document for the string "s3:PutObjectTagging", and no search for an action name can find a route that names no action.
Against MinIO RELEASE.2025-09-07T16-13-09Z, stood up from CI's own contract fixture and handed the app policy verbatim, the upload succeeded with carlos-scan=clean attached.
AWS documents x-amz-tagging as requiring s3:PutObjectTagging, so a deployment whose app buckets are real AWS S3 was probably never exposed. That is where Tito's are — NewAWSProvisioner builds its IAM client with no endpoint override, deliberately ("IAM has no S3-compatible twin"), so app users are AWS IAM users whose keys authenticate against AWS. But CARLOS_STORE_ENDPOINT exists precisely so a self-hoster can point at something else, and self-hosters are in scope for this feature by decision. The property has to hold on the gateway, not on the paperwork.
What it would have bought an attacker
The sweep reads the tag before scanning and skips any object whose tagged version matches the current one. The version is etag@last-modified: the ETag is the app's own MD5, and the last-modified is the server clock at upload — so an app can upload, HEAD its own object, and retry until the stamp it guessed matches.
The object is then never scanned, and carlos store scan status reports it clean, indefinitely. The two-sentence audit answer the whole feature exists to produce becomes a lie the platform tells confidently. That is worse than having no scanner.
The obvious fix does not work
This is the part I would not have predicted.
Adding an explicit Deny on s3:PutObjectTagging and s3:DeleteObjectTagging was the first thing tried. The upload still succeeded — the gateway never attributed the header to that action, so denying the action denied nothing.
What closed it was denying s3:PutObject itself when the request carries one of our tag keys:
{"Effect": "Deny", "Action": ["s3:PutObject"], "Resource": "<bucket>/*",
"Condition": {"ForAnyValue:StringEquals": {
"s3:RequestObjectTagKeys": ["carlos-scan", "carlos-scan-version", "carlos-scan-at"]}}}
Both Denys are now in the policy, unconditionally — not gated on whether enforcement is on, because a store that is not enforcing is exactly where a forged clean tag would sit unnoticed longest.
What changed besides the policy
The tests had encoded the old belief in five places: "two statements when enforcement is off", "Effect":"Deny" used as a proxy for "enforcement is on", and the grep helper itself. All five now say what they meant, and assertNoPutObjectTagging checks both routes.
TestDeliveredContractMinio gains the live half, against the real server .amadan/ci.d/30-minio-contract already runs on every push. Verified end to end: the policy the code generates, handed to MinIO verbatim, full contract test green against it.
The lesson, which is not "add a Deny"
A rule stated as an absence — this permission must never be granted — can only ever be checked by looking for the thing that is absent. It cannot see a second door. The rule was necessary, it was correctly enforced, and it protected the wrong shape of the property.
The version that catches this class is the one that asserts the behaviour against a real implementation: not "the policy does not contain this string" but "this credential cannot put this tag, by any route we can think of". That test now exists and runs on every push, which is the only reason the next second door will be found before a deployment rather than after one.
Related: the same contract test now also pins that HeadObject and ListObjectsV2 agree on the version string, which was the other thing scheduled to be discovered by deploying.