Tools, permissions, and blast radius

An agent is exactly as dangerous as the tools you hand it, and no more capable either.

The idea

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.

An agent surrounded by tools, each with a granted scope and a blast-radius ring, and a task carrying an injected instruction trying to reach a write tool

How it 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.

Cost

Scope grantedWhat it enablesBlast radius if the agent is steered
NoneNothingNone
Read, one resourceAnswering, summarising, draftingAnything in that resource can be repeated somewhere else
Write, one resourceActually finishing the taskEvery record in that resource can be changed
AdminFixing its own permissionsEverything, 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.

Watch out for

Worked example

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.

Check yourself

Your agent needs to read from a database to answer questions. What do you grant it?