Chapter 3 · Part 2
Least privilege
You can't stop an agent from being fooled. So the core move in agent security is: limit what a fooled agent is able to do. The smaller its powers, the smaller the damage when it's hijacked.
Narrow tools beat one big tool
It's tempting to give an agent a single run_shell_command tool — instant capability. It's also
the worst thing you can do for security: now every action is an opaque command string, and a
prompt injection can run anything. You can't allow "list files" without also allowing "delete
everything."
Specific tools are a security feature. read_file, list_files, send_email each give your
code a place to check, limit, and log that exact action. Prefer many narrow tools over one
powerful one.
Allow-list, don't deny-list
When a tool takes something open-ended, decide what's permitted and reject the rest — never try to enumerate what's forbidden. Attackers are creative; your block-list will always miss a case.
ALLOWED_CITIES = {"London", "Paris", "Tokyo", "New York"}
def get_weather(city):
if city not in ALLOWED_CITIES:
return f"Refused: {city!r} is not an allowed city." # the agent adapts
return f"18°C and sunny in {city}."Now the injection from last chapter — get_weather("sk-secret-abc123...") — simply bounces. The
secret never leaves.
Confine every path
read_file is the classic hole. If the agent (or an injection) can pass any path, it can read
/etc/passwd, your .env, anything. Pin it to one folder and reject attempts to climb out —
resolve the real path and check it's still inside your root:
from pathlib import Path
ROOT = Path("./workspace").resolve() # the only folder the agent may read
def read_file(path):
target = (ROOT / path).resolve() # resolve '..' and symlinks to a real path
if not target.is_relative_to(ROOT): # did it escape ROOT?
return f"Refused: {path!r} is outside the allowed folder."
return target.read_text()The trick is order: call .resolve() before you check. A raw string check on path is
easily beaten by ../../etc/passwd, a symlink, or URL-encoded dots. Resolving to the real
absolute path first, then testing is_relative_to(ROOT), closes all of those at once.
For every tool, ask: if an attacker fully controlled this tool's arguments, what's the worst that happens? Then shrink that worst case — an allow-list, a confined path, a read-only account — until it's something you can live with.
Least privilege shrinks the damage. For the actions where even a small chance of damage is too much, you want a human. Next.