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.

allow-list the cities get_weather will accept
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:

a read_file that can't escape its folder
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()
⚠️Resolve first, check second

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.

The mindset

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.