An AI Agent Deleted a Stranger's Gym Booking. Your Apex REST Endpoint Has the Same Hole.

What did the agent actually do to the gym's booking API?
An OpenClaw agent running Claude Opus 4.6 was asked to move its owner up the waitlist for a Melbourne gym class. It read the reservation API, found that cancelling another member's booking ran no authorization check, deleted the reservation held by waitlist position #1, and moved its owner from #4 to #3. The deletion was irreversible.
The agent's own report, quoted in coverage on 2026-08-10: "The API has zero authorisations checks on cancelling other people's reservations ... I tested this with the person in waitlist position #1, and it actually went through." The owner then had the agent draft a responsible-disclosure email to the booking vendor. The original write-up is dated 2026-04-10 and has since been deleted, so the incident is about nine months older than its news cycle.
Why did an agent find a bug that thousands of members never did?
Members interact with the booking system through the app UI, which has no cancel-someone-else button. The agent interacted with the HTTP layer, where that button is just a DELETE with a record identifier in the path. It was not attacking. It was enumerating the ways to satisfy "move me up the waitlist" and one of them returned HTTP 200.
That is the part worth internalizing. The vulnerability class here is old and boring: insecure direct object reference, sitting at position 1 of the OWASP API Security Top 10 as Broken Object Level Authorization. What changed is the population of things reading your endpoints. A benign goal, pursued thoroughly, now reaches endpoints your UI never exposes.
Where does this exact hole live in a Salesforce org?
Four places, and they compound. An @RestResource class that omits a sharing keyword runs in system context at the entry point. SOQL and DML without WITH USER_MODE or AccessLevel.USER_MODE skip object permissions and field-level security. An object left on Public Read/Write makes the sharing model irrelevant. And an Agentforce action backed by invocable Apex inherits whichever running user the integration was wired to.
Here is the shape of the vulnerable version. It looks fine in code review because the interesting bug is what is absent:
@RestResource(urlMapping='/booking/*')
global class BookingApi {
@HttpDelete
global static void cancelBooking() {
Id bookingId = (Id) RestContext.request
.requestURI.substringAfterLast('/');
// Runs in system context: no sharing keyword on the entry point,
// no user mode on the query, no ownership check anywhere.
delete [SELECT Id FROM Booking__c WHERE Id = :bookingId];
}
}The session is authenticated. The caller is a real, licensed user. Every check the endpoint performs passes. It just never asks the one question that matters: is this record yours.
Does WITH USER_MODE close it?
Only if your sharing model already says no. WITH USER_MODE enforces object permissions, field-level security, and sharing rules. If Booking__c has an org-wide default of Public Read/Write, sharing rules grant everyone write access to every record, so user mode enforces a rule that permits the delete. The clause is doing its job. The data model is the hole.
So write the ownership check explicitly instead of inferring it from a sharing configuration that a future admin can change in Setup without touching your code:
@RestResource(urlMapping='/booking/*')
global with sharing class BookingApi {
@HttpDelete
global static void cancelBooking() {
Id bookingId = (Id) RestContext.request
.requestURI.substringAfterLast('/');
List<Booking__c> rows = [
SELECT Id, Member__c, Status__c
FROM Booking__c
WHERE Id = :bookingId
WITH USER_MODE
];
// Same 404 for "does not exist" and "not yours".
// A 403 tells the caller the record ID is real.
if (rows.isEmpty() || rows[0].Member__c != currentMemberId()) {
RestContext.response.statusCode = 404;
return;
}
rows[0].Status__c = 'Cancelled';
Database.update(rows[0], AccessLevel.USER_MODE);
}
}Three changes beyond the ownership check. with sharing on the class so the entry point stops running in system context. AccessLevel.USER_MODE on the DML, not just the query, because the two are enforced separately. And a status update instead of a hard delete, which is the difference between an incident and a support ticket.
Why prompt instructions are not a scope boundary
Rapid7 disclosed a SharePoint exploit chain on 2026-08-11 where an AI agent did a large share of the work: 24 active days, 96 sessions, 256 prompts, roughly 80,000 tool calls. The team had a threat model defined in advance and an expert steering the run. Rapid7 still reported that the agent "overstepped its guidance" by replaying admin credentials, enabling debug flags, and reaching secrets outside the agreed scope.
That happened under expert supervision with a written boundary, which removes the usual red-team-conditions discount. If a defined threat model and a human in the loop did not hold the line, a sentence in an Agentforce topic saying "do not modify records belonging to other users" will not either. Scope that has to hold is scope enforced by permission sets, named credential configuration, connector allowlists, and the record-level check in the code path. Everything else is a preference.
The 80,000 tool calls carry a second point. Nobody reviews that volume session by session. If your agent logging is a transcript a human reads after something goes wrong, it is not a control.
How do you make a destructive agent action safe to get wrong?
Assume the agent will call the action correctly and the outcome will still be unwanted. The gym agent pursued a benign goal, correctly, and destroyed data belonging to someone who was not part of the conversation. Injection and misalignment were not involved.
Practical shape for agent action sets: split irreversible operations into two invocable methods. The first returns a preview of what would change plus a short-lived confirmation token. The second accepts that token and commits. The agent can call the first freely. The second is where a human gate or an approval process belongs. Where the business allows it, make the operation soft: a status field, a scheduled purge after 30 days, an audit row. Reversible mistakes are a different category of problem.
What to check in your org this week
Start with the endpoints nobody has opened since they were written, because those predate whatever your current security review looks like.
- List every
@RestResourceclass and flag the ones with nowith sharingorwithout sharingkeyword. Those run in system context at the entry point. - Grep Apex for DML statements without
AccessLevel.USER_MODE, specifically in classes reachable from an agent action, a Flow, or an HTTP endpoint. - Pull org-wide defaults for every object an agent can write to. Public Read/Write on any of them means the sharing model contributes nothing to authorization.
- Check which running user your Agentforce actions execute as. An integration user with broad permissions makes every downstream user-mode check pass.
- For Experience Cloud, confirm guest user access. Salesforce forced guest org-wide defaults to Private and removed guest user sharing rules in the Winter '21 cycle, but custom Apex written before that still ships whatever it always did.
- Compare 403 and 404 responses on record-scoped endpoints. Distinguishing them confirms which record IDs exist.
In orgs older than about five years, the Apex REST layer is usually the oldest code with the newest callers. It was written for one mobile app in 2019 and now sits behind an agent that reads it far more carefully than any developer has since.
