Tools You Wrote, Safely 🛡️
Now the tools become yours. This is also the chapter where we stop trusting the model's input, because the moment a tool touches your filesystem the stakes change completely.
Build a tool registry so adding abilities is easy, and a sandbox so they cannot be turned against you.
about 35 minutesA registry instead of a growing if-chain
Chapter 8's run_tool had one if. With six tools that becomes a
mess, and the tool list and the dispatcher drift apart until one has an entry the other
does not. Keep them together:
from datetime import datetime
from pathlib import Path
# A registry: one dictionary mapping a tool name to the function that
# implements it. Adding a tool becomes adding an entry, not editing a
# growing chain of if/elif.
REGISTRY = {}
def tool(name, description, schema=None):
"""Decorator that registers a function as a tool."""
def wrap(fn):
REGISTRY[name] = {
"fn": fn,
"spec": {
"name": name,
"description": description,
"input_schema": schema or {"type": "object", "properties": {}, "required": []},
},
}
return fn
return wrap
@tool("get_current_time", "Get the current date and time on the user's computer.")
def get_current_time():
return datetime.now().strftime("%A %d %B %Y, %H:%M")
@tool("add_note", "Append a line to the user's notes file.",
{"type": "object",
"properties": {"text": {"type": "string", "description": "The line to add."}},
"required": ["text"]})
def add_note(text):
return f"Noted: {text}"
def tool_specs():
"""The list you pass to the API."""
return [entry["spec"] for entry in REGISTRY.values()]
print("registered:", sorted(REGISTRY))
print("specs sent to the API:", [s["name"] for s in tool_specs()])
print("add_note requires:", REGISTRY["add_note"]["spec"]["input_schema"]["required"])
registered: ['add_note', 'get_current_time']
specs sent to the API: ['get_current_time', 'add_note']
add_note requires: ['text']
The decorator registers the function and its description in one place, so a tool cannot exist in the API list but be missing from the dispatcher, or the reverse. Adding an ability is now writing one function.
Dispatch that cannot crash your program
A tool call comes from a language model, which means it is input, and input is never trusted. It can name a tool that does not exist. It can pass an argument you never declared. Neither should end your session:
def add_note(text):
return f"Noted: {text}"
# The registry from above, in miniature so this block runs on its own.
REGISTRY = {"add_note": {"fn": add_note}}
def run_tool(name, tool_input):
"""Look up and run a tool, turning every failure into a message.
Two rules here, and both matter:
1. An unknown name is not a crash. A model can ask for a tool that
does not exist, and that must not take the program down.
2. An exception inside a tool is not a crash either. It becomes text
the model can read and react to, which is far more useful to it
than a traceback is to you.
"""
entry = REGISTRY.get(name)
if entry is None:
return f"Error: no tool called {name!r} exists."
try:
return str(entry["fn"](**tool_input))
except TypeError as exc:
return f"Error: wrong arguments for {name}: {exc}"
except Exception as exc:
return f"Error running {name}: {type(exc).__name__}: {exc}"
print(run_tool("add_note", {"text": "buy milk"}))
print(run_tool("no_such_tool", {}))
print(run_tool("add_note", {"wrong_argument": 1}))
Noted: buy milk
Error: no tool called 'no_such_tool' exists.
Error: wrong arguments for add_note: add_note() got an unexpected keyword argument 'wrong_argument'
Returning the error as a string rather than raising is the important move. The text goes back to the model as a tool result, it reads "no tool called that exists", and it apologises and tries something else. Your assistant recovers from its own mistakes instead of dying.
The part where we stop trusting it
Everything so far has been convenience. This bit is not.
The instant you write a tool that reads or writes files, you have created a way for text to reach your filesystem. The text arrives from a model, which may be repeating something it read in a document, which may have been written by someone else. The rule is simple and absolute: validate in your Python, never in the prompt.
from pathlib import Path
# The one directory the assistant is allowed to touch.
NOTES_DIR = Path("notes").resolve()
def safe_path(filename):
"""Resolve a filename inside NOTES_DIR, refusing anything that escapes.
The attack this stops is "../../.ssh/id_rsa". resolve() expands all the
".." parts into a real absolute path, and then we simply check that the
result is still inside the folder we allow. Checking BEFORE resolving
is the classic mistake, because ".." has not been applied yet.
"""
candidate = (NOTES_DIR / filename).resolve()
if candidate == NOTES_DIR or NOTES_DIR in candidate.parents:
return candidate
raise ValueError(f"Refused: {filename!r} is outside the notes folder.")
ok = safe_path("groceries.md")
print("allowed:", ok.name, "| inside notes:", NOTES_DIR in ok.parents)
for attack in ["../secrets.txt", "../../etc/passwd", "/etc/passwd"]:
try:
safe_path(attack)
print("LEAKED:", attack)
except ValueError as exc:
print("blocked:", attack)
allowed: groceries.md | inside notes: True
blocked: ../secrets.txt
blocked: ../../etc/passwd
blocked: /etc/passwd
Read that check carefully, because the ordering is the whole point.
resolve() is called first, which turns
notes/../../etc/passwd into a real absolute path with the
.. already applied. Only then do we ask whether the result is still inside
the allowed folder. Checking for suspicious-looking strings before resolving is the
classic mistake: there are more ways to write "go up a directory" than you can enumerate,
and you will miss one.
- One allowed directory, checked with the resolve-then-compare pattern above.
- No shell. Never pass model output to
os.systemorsubprocesswithshell=True. There is no safe way to quote your way out of this. - Read freely, write narrowly, delete never. A tool that appends to one file is fine. A tool that removes files is a bad trade for a personal assistant.
- Confirm anything irreversible. If a tool sends an email or spends money, print what it is about to do and require you to type "yes". The model does not get a vote.
Why the prompt is not a safety mechanism
You could write "never read files outside the notes folder" in your persona, and it would mostly work. Mostly is the problem. Instructions are advice; a model can be talked out of advice, and it can be confused by text it reads inside a document.
safe_path cannot be talked out of anything. It is arithmetic on paths. That
is the difference between a request and a boundary, and it is why the check lives in
Python rather than English.
Good tools to add next
- read_note(filename) and list_notes(), both through
safe_path. These make chapter 10 possible. - append_note(text) so you can say "remind me that the boiler is serviced in March".
- do_maths(expression) using
ast.literal_evalor a small parser. Nevereval.
Notice what is missing: nothing here runs arbitrary code, opens a shell or reaches the network. Those are all possible and all a much bigger conversation about risk. A personal assistant that reliably reads your notes is more useful than a fragile one that can theoretically do anything.
If it went wrong
- The decorator runs but the tool is never offered You passed
TOOLSinstead oftool_specs()to the API call. TypeErrorabout keyword arguments Yourinput_schemaproperty names must match your function's parameter names exactly, because dispatch uses**tool_input.safe_pathrejects a legitimate file Thenotesfolder does not exist yet, soresolve()produces something unexpected. Create it withNOTES_DIR.mkdir(exist_ok=True)at startup.
Your assistant has at least two tools of your own, adding a third is one decorated function, and a tool asked for ../../etc/passwd refuses politely instead of complying or crashing.