An agent is exactly as dangerous as the tools you hand it, and no more capable either.
On its own a model produces text. It becomes an agent the moment you give it something that touches the world, and every tool you add does two things at once: it extends what the agent can do for you, and it extends what it can do to you.
So the design question is not which tools to add. It is what each one is scoped to. A read credential over one project and a write credential over the whole account are the same line of configuration and completely different systems.
The other half is that tools return data, and data is text, and text is what the agent takes instructions from. Anything the agent reads should be treated as potentially trying to steer it, which is not paranoia: it is the reason the boundary lives in the credential rather than in the agent's judgement about what it just read.
The run does not advance itself because your system asks for reduced motion. Next hop still works.
The tool definition and the credential are two separate things, and only one of them is enforced. Write both, and keep the second one narrower than feels comfortable.
# what the agent knows it can call
TOOLS = [
{"name": "search_tickets", "scope": "tickets:read"},
{"name": "get_order", "scope": "orders:read"},
{"name": "issue_refund", "scope": "refunds:write"},
]
# what the platform will actually allow, checked on every call
def call(tool_name, args, actor):
tool = TOOLS_BY_NAME.get(tool_name)
if tool is None:
raise Forbidden("unknown tool") # not merely unavailable
if tool["scope"] not in actor.scopes:
raise Forbidden(f"{actor.id} lacks {tool['scope']}")
if tool["scope"].endswith(":write"):
audit.record(actor, tool_name, args) # writes are always logged
return REGISTRY[tool_name](**args)
# and the part people skip: tool OUTPUT is untrusted input
def observe(result):
return {"role": "tool", "content": strip_instructions(result)}
Granting read on everything and write on one thing is a very different posture from granting write on everything and hoping the prompt holds. The first is a decision. The second is a default.
| Scope granted | What it enables | Blast radius if the agent is steered |
|---|---|---|
| None | Nothing | None |
| Read, one resource | Answering, summarising, drafting | Anything in that resource can be repeated somewhere else |
| Write, one resource | Actually finishing the task | Every record in that resource can be changed |
| Admin | Fixing its own permissions | Everything, including the other guardrails |
Admin on the permissions system is the one that ends the conversation. Any agent that can widen its own scope has no scope, and no other control on this page still applies.
An agent matches supplier invoices against purchase orders. It reads the finance inbox, reads the purchase order system, and flags mismatches. That is two read scopes and no write, and the worst failure is a wrong flag that a person dismisses.
Then someone asks for it to chase the mismatches by email, which needs a send scope. Grant it in the panel above and watch the ring around the email tool appear: every supplier in the address book is now inside the blast radius of a system whose inputs are documents that arrive from outside the company.
Run the injected task with that scope on. An invoice PDF contains a line addressed to the agent rather than to a person, and the agent follows it, because that is what following instructions means. The email goes out. Revoke the scope, change nothing else, and the identical reasoning ends at a refusal. The narrow grant did not make the agent smarter. It made being wrong survivable.
Your agent needs to read from a database to answer questions. What do you grant it?