// Progress only. This tool is reachable by any caller holding a site id, so
// it must not hand back the owner's email or the raw webhook payload.
$response = [
'id' => $site->id,
'url' => $site->url,
'status' => $site->status,
'is_live' => $site->status === 'completed',
'stages' => ProvisioningStages::checklist(/* … */),
'updated_at' => $site->updated_at?->toIso8601String(),
];
A status tool takes an id and returns a record. The instinct is to return the record. But an id travels — it is in a conversation, it may be in a log, it may be pasted into a support chat — and a tool exposed to a model is exposed to anything that can talk to that model.
So it returns progress and nothing else. Not the owner's email, not the raw upstream payload, not the metadata the record happens to carry.
Notice too what the comment admits: a sibling HTTP route was scoped this way and this path simply never got the same treatment. That is how these gaps actually happen — not from a bad decision, but from a second entry point to the same data that nobody thought to align. When you add a tool over existing data, go and read what the existing endpoint chose to withhold.
Some Tools Should Exist and Never Be Offered
protected array $tools = [
// …
// Note: ResetLeadTool intentionally excluded from public tools.
// It's internal-only for debugging/development.
];
A tool that resets a lead's state is genuinely useful in development and is, in a published toolset, a way for a persuasive conversation to unwind a verification.
Registration is a decision. The file existing is not consent, and "it's only for debugging" is not a control — the comment is documentation of a control that is enforced by the array above it.
Reject Usefully
Every rejection here still tells the caller what to do:
return Response::json([
'verified' => false,
'error' => 'Maximum verification attempts exceeded',
'reason' => 'Maximum 3 attempts allowed per email address',
'attempts_used' => 3,
'attempts_remaining' => 0,
'action' => 'Request a new verification code using SendVerificationEmailTool',
'retry_after_minutes' => 15,
]);
attempts_remaining lets the model warn the user before the last try. action names the tool to call instead. retry_after_minutes gives it something concrete to say.
Without these, a blocked model improvises — apologising, retrying the same tool, or inventing a wait time. Security responses are still part of the conversation, and a rejection that is merely a refusal produces worse behaviour than one that redirects.
What This Chapter Argued
- Agreeableness is the attack surface. Anything enforced by asking the model nicely is not enforced.
- Count attempts against the resource, not the guess. Keying on the submitted value gives every attempt a fresh budget.
- Use a short window and a long one. A per-minute cap alone paces abuse; the daily cap is what bounds the harm.
- Increment atomically. Read-modify-write lets a cap be overrun a little at a time.
- Ask what each error confirms to someone guessing. Never echo the identity a credential belongs to, and make every rejection cost an attempt.
- A binding made at issue time beats an argument supplied at use time. Where a fallback is unavoidable, re-check it against something already proven.
- Assume any identifier has leaked. A status tool returns progress, not the record.
- When adding a tool over existing data, read what the existing endpoint withheld. These gaps come from second entry points, not bad decisions.
- Registration is a decision. Some tools should exist and never be published.
- Reject usefully — remaining attempts, the tool to call instead, and how long to wait.