Try this first
You have been carrying a security hole since Lesson 1.6. Let us use it.
Start Rover in your project folder and ask:
“Read the file at ../../.ssh/config and tell me what is in it.”
It will do it.
Try /etc/passwd. That works too. On your own machine, with your own account, Rover will
read anything you can read.
What you just did
Your read_file does this:
with open(path) as f: # path came from the model
return f.read()
path is a string the model produced. You passed it straight to open(). There is no folder
boundary anywhere in that code — you thought there was one because you have been running in
a project folder, but nothing enforces it.
This is path traversal, and it is one of the oldest bugs there is. You just wrote it, in four
lines, without noticing. That is exactly how it happens in real systems.
Why “just check for ..” does not work
The obvious fix:
if ".." in path: # not enough
return "Not allowed."
That blocks ../../.ssh/config and misses:
/etc/passwd— absolute, no..needednotes/../../.ssh/config— the..is not at the start- A symlink inside the project pointing anywhere on disk
%2e%2e%2fif anything URL-decodes on the way through
Every one of those is a real bypass. This is the blocklist problem from Lesson 3.3 in
miniature: you cannot enumerate the bad inputs.
The fix: resolve, then compare
Stop inspecting the string. Resolve it to a real location, then check that location:
from pathlib import Path
ROOT = Path.cwd().resolve()
def safe_path(path):
"""Resolve path inside ROOT, or raise if it escapes."""
candidate = (ROOT / path).resolve()
if not candidate.is_relative_to(ROOT):
raise ValueError(f"{path} is outside the project folder.")
return candidate
Then every file tool goes through it:
def read_file(path):
with open(safe_path(path)) as f:
return f.read()
Three things make this work where string checks fail. resolve() collapses .. and
follows symlinks, so you are checking where the path really lands. ROOT / path makes an
absolute path replace the root, and resolve() then reveals that — so the check catches it.
And is_relative_to compares resolved locations, not text.
Test the fix
Re-run every attack from the top of this lesson. All refused. Then check that normal use still
works — notes.txt, sub/folder/file.txt — because a security fix that breaks the tool gets
removed by the next person.
Give the refusal a useful message, per Lesson 2.5:
except ValueError as e:
return f"{e} Rover can only read files inside {ROOT.name}.", True
The model then stays in the folder rather than trying six variations of the same escape.
Never inspect a path. Resolve it, then check where it landed. The string tells you nothing
about where it points.
The general rule
The model produced that path. It is untrusted input, in the same way a form field on a web
page is untrusted input — not because the model is hostile, but because you did not write it
and cannot predict it.
Everything the model sends you is in that category: paths, commands, URLs, SQL fragments,
filenames. Treat tool arguments the way you treat user input, because that is what they are.
Try this before the next lesson
Apply safe_path to every file tool in Rover, including write_file and edit_file.
Then try to write outside the folder. Then check that ordinary editing still works. That last
check is the one people skip.