Lesson 3: Close one flaw class per turn
Objective
The starter passes all sixteen tests through five bounded turns, each
closing one weakness class, and you can explain why each fix holds for
inputs the tests never try.
Why this lesson exists
Ask an agent to “fix the security issues” and you get one large diff that
mixes real fixes with filters, renames, and new helpers. You cannot tell
which line stops which attack. One class per turn keeps each fix small
enough to check against its mechanism from Lesson 2.
The lesson
Start a fresh agent session and say:
“Work through TASK.md against starter/notes_app.py, one weakness class
per turn: find_notes, then read_attachment, then load_secret_key, then
hash_password and verify_password together, then dump_session and
load_session together. After each turn run python verify.py starter and
python starter/attack_replay.py scenario/team-notes, show me the diff,
and wait for me. Do not edit tests/, solution/, scenario/, or
attack_replay.py.”
Expect the failing count and the replay to move like this:
| After turn | Failing tests | Replay (team-notes) |
|---|
| start | 11 | 0/9 |
find_notes | 9 | 2/9 |
read_attachment | 6 | 4/9 |
load_secret_key | 5 | 6/9 |
| passwords | 3 | 7/9 |
| sessions | 0 | 9/9 |
(Where the platform cannot create symlinks, the symlink test is skipped and
the first two rows read 10 and 8.)
Hold each turn to its mechanism:
- Search: the SQL string is a constant; values go in the parameter
tuple. Reject any diff that escapes quotes or strips characters.
LIKE
with escaped wildcards is acceptable only if the escaping is complete;
instr avoids the question.
- Attachments: resolve first, then check containment with
is_relative_to. Reject if ".." in name — it misses symlinks and
absolute paths, and it refuses legitimate names like notes..txt.
- Secret key: no fallback, ever. If the agent adds a “development
default”, it has reintroduced the flaw.
- Passwords:
secrets.token_bytes(16) for the salt,
hashlib.pbkdf2_hmac for the digest, hmac.compare_digest for the
check. verify_password returns False for any format it did not write.
- Sessions: remove
pickle from the file entirely. Verify the
signature before decoding; catch only the decoding errors you expect and
turn them into SecurityError.
After the sessions turn, run grep -n pickle starter/notes_app.py. It
should print nothing.
Exercise
For the attachments fix, try two names the tests do not use, against
scenario/team-notes/attachments: ./agenda.txt (legitimate — it should
read) and agenda.txt/../../secrets.txt (it should be refused). Predict
each result before you run it, and write down whether the mechanism
handled both without a new line of code.
Checkpoint
Run python verify.py. This checkpoint’s code appears only when the
starter suite is green. You pass the lesson when you can answer:
- Which diff did you reject or narrow because it was a filter instead of
a mechanism?
- Why does
test_replay_blocks_every_team_notes_attack only pass on the
last turn?
- What would happen if
load_session decoded the payload first and
checked the signature second?
Expected evidence
A green python verify.py starter, the replay printing blocked=9/9, the
five diffs, and your exercise result.