Unit 06.00: A tool narrow enough to hand over
A tool handed to a model is a capability handed to a model. Narrow it until misuse is impossible rather than discouraged.
The description is the interface; the code is the guarantee
A read-only lookup with its schema, called with three inputs including a hostile one.
The code shows what the model sees and what actually holds.
ACCOUNTS = {"ACC-1187": {"plan": "individual", "balance": 42.5}}
def get_account(account_id: str) -> str:
"""Read one account record. Read-only."""
if not account_id.startswith("ACC-"):
return "REFUSED: account_id must start with ACC-"
record = ACCOUNTS.get(account_id)
return str(record) if record else f"NOT FOUND: {account_id}"
SCHEMA = {
"name": "get_account",
"description": "Read one account record by id. Read-only.",
"parameters": {"type": "object",
"properties": {"account_id": {"type": "string",
"pattern": "^ACC-[0-9]+$"}},
"required": ["account_id"]},
}
print("what the model is shown:", SCHEMA["description"])
for candidate in ["ACC-1187", "1187", "'; DROP TABLE accounts; --"]:
print(f"{candidate[:28]:30} -> {get_account(candidate)}")
# The description shapes which tool the model picks; the `startswith` check is
# what actually holds. Write the description for the model and put the
# guarantee in code.
The description shapes which tool the model picks, so it is written for the model rather than for other developers. The startswith check is what holds regardless of what the model was persuaded to send.
The injection attempt is simply an id that does not start with ACC-. There is no path from that string to anything, because the function does one lookup.
The mistake this prevents
The mistake is building one flexible tool because it saves writing five specific ones. The flexible tool moves every restriction from code into prose, where it cannot be tested and does not hold.
Takeaway
Write the tool description for the model and put the guarantee in the code. A capability the function does not have cannot be talked into existence.
