The tool list is the boundary
Ask an agent to review your infrastructure without changing anything, and there are two ways to get it. Tell the model not to write. Or hand it a tool list with no write tools in it.
Only one survives a model that misreads the instruction.
OBTO's planning modes take the second route for the four tools that edit app artifacts. In plan or explain mode, those four are filtered out of the catalog before the model is given it:
const PLAN_WRITE_TOOLS = new Set([
"obto_upsert_record",
"obto_patch_artifact",
"obto_create_route",
"obto_update_route",
]);
tools: readOnlyMode
? this.tools.filter((t) => t && !PLAN_WRITE_TOOLS.has(t.name))
: this.tools,The model is never offered obto_upsert_record. It cannot decide to call it, because from inside that session the tool does not exist. No instruction to forget, no prompt to talk around.
Note the size of that set, because it is the honest boundary. Four tools are removed. Other mutating tools stay in the catalog, governed by a line in the prompt telling the model not to modify anything. For those, plan mode is an instruction. Naming which is which is the point of what follows, and we do not exempt ourselves from it.
The distinction is worth naming, because "the agent can't do that" gets said about four different things, and a buyer is entitled to know which one is on offer.
Four grades of enforcement
| Grade | What it means |
|---|---|
| Removed | The capability is absent from what the caller is offered. |
| Refused | The server rejects the call unconditionally. The write is not applied. |
| Checked | A precondition runs when the caller supplies it. Omit it and the write proceeds. |
| Advised | Documented somewhere. No server-side consequence. |
The word "guardrail" covers all four without distinguishing them. The gap between the second and third is where the surprises live.
Removed
Plan mode is the clearest case, for the four tools it covers. A second sits in the dynamic tool loader, where a tool record that is not active is never registered into the session. It does not become a tool that returns an error when called. It never enters the catalog.
Removal is the only grade that does not depend on the model behaving. Everything below it does, at least a little. If you are deciding how much of your platform an agent may reach, the size and shape of the catalog you hand it is the strongest control you have.
Refused
Below removal sits the server saying no. Rejecting a malformed payload is table stakes. The refusals worth examining fire when the platform's own check cannot run.
Creating a route on OBTO looks up whether that route already exists. If it does, the create is refused. If the lookup itself errors, the create is refused too, with read_failed, on the principle that a tool which cannot see the target has no business writing to it. Schedule job creation does the same under collision_check_failed.
A check that could not run is treated as a check that failed.
That is the invariant, and it is worth more than any individual guard. Tenant reset carries it furthest, across two layers. A hardcoded set of platform and reserved slugs is refused unconditionally, with deliberately no override parameter. A second list, held as a configuration property, covers real production tenants. When that property cannot be read, the operation stops: that list is the only guard covering real tenants, so it refuses rather than proceed with the guard disabled. Nothing is read and nothing is written.
This part is structurally awkward for a pipeline, and not because pipelines are badly built. In a build-and-deploy model the precondition is evaluated in one process and the write happens later in another. "The check errored" and "the check passed" both arrive at the deploy step as "the job moved on", unless somebody wired the failure path by hand. When the check and the write are the same call, that path is the default rather than something you remembered to configure. We looked at the wider version of this in the questions a deploy pipeline answers for you.
Checked
obto_patch_artifact accepts two preconditions. anchorText carries the text you expect to find at the line you are editing. expectedContentHash carries a hash of the whole file as you last saw it. Supply either and a patch against changed content is refused before anything is written. That mechanism is real, and we have described how it works.
Both are optional. Omit them and the patch applies at the line numbers given. A third case is worth knowing: on a pure insertion, an anchor you did supply is accepted and then never compared. So the honest sentence is "a patch is refused when a supplied anchor does not match a replacement it applies to", which is narrower than "a patch cannot be applied to a file that changed underneath you". The first is a check the caller opts into. The second would be a guarantee, and we do not have it. An optional argument is not a gate.
The approval step in the hosted agent sits at the same grade, and its scope is narrower than "destructive tool calls" suggests. Six tools are covered: four always intercepted with approve or reject, plus artifact patches and overwrites of an existing record, which also offer edit. It is on by default outside read-only mode. It is scoped to that agent, so an external client on the same MCP endpoint does not pass through it, and the lookup that classifies an overwrite fails open if it throws. Useful, on by default, not a property of the server.
Advised
The bottom grade is documentation. A tool description saying "never auto-activate" is a promise about the calling convention. Only the handler is a promise about behaviour. The two can drift, and when they do, the description is the one that reads well in an evaluation.
So do not ask what the docs say a tool does. Ask what the code does when you pass the argument the docs told you not to pass.
Three things you can measure
- Count the catalog. Start a read-only session and ask for the tool list. Any mutating tool still in it is governed by an instruction rather than a boundary. Run this on us: four tools removed, the rest of the mutating surface still listed. That is the honest shape of our read-only mode today.
- Break the precondition store. Make the lookup a safety check depends on unreachable, then attempt the write. Whether it lands tells you which way the system fails, and that only ever shows up when something else has already gone wrong.
- Pass the ignored parameter. Send the field the docs say is defaulted or ignored, and see whether the stored record agrees.
What this does not buy you
Scoping a catalog bounds what an agent can reach. It says nothing about whether what it writes is right. Hash and anchor matching catch corrupted bytes and stale edits, not a well formed change that is simply wrong. An agent with exactly the correct tools can still be confidently incorrect with every one of them, which is why knowing the blast radius up front matters as much as the guards during the run, and why reading back what was stored is not optional.
Tests, review, and a person who cares are still yours. What a scoped catalog does is make the set of things you have to reason about smaller and knowable in advance. That is a narrower claim than "safe", and a more testable one.
If you are evaluating platforms in this space, the four grades are a decent worksheet. Sort each safety claim into a column, then ask the vendor to point at the line of code that puts it there.