fix(M7-27+capstone): apply AI-drives-git reframe, lesson=theory, de-slop course-wide

Phase 2 sweep — all modules are post-pivot, so the learner directs the AI agent
(Claude Code as the worked example) to do the git/setup work and verifies, instead
of typing commands by hand; no re-teaching basics. Lesson sections are theory with
example output; all execution lives in the labs. De-slopped ("prose" etc. gone
course-wide, em-dash density thinned). /path/to placeholders -> ~/ai-workflow-course.

Every deliberate teaching device verified intact: M10 ai-change.patch trap,
M12 bad-clear-snippet, M13/M27 planted pending_count bug, M15 secret+typosquat+MD5,
M18 BREAK=1, M21 absent-.gitignore, M22 poisoned skill, M24 no-op patch, M25 --simulate.
Labs compile/parse (py/sh/yaml/json); no junk.

Closes #83
Closes #86
Closes #89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TfzV5QvtPDz8LJS3Pu5VLT
This commit is contained in:
2026-06-22 21:58:17 -04:00
parent a29823f4b3
commit f925fd9645
38 changed files with 1735 additions and 1424 deletions
@@ -1,16 +1,16 @@
# Module 17 — Secrets, Config, and Environments
> **Ask an AI to "connect to the API" and it will cheerfully paste your secret key straight into
> a source file the one place it must never go.** This module gives you the standard, boring,
> correct place to put secrets and per-environment config instead, and a reflex for catching the
> AI when it does the wrong thing.
> **Ask an AI to "connect to the API" and it will paste your secret key straight into a source
> file, the one place it must never go.** This module gives you the standard, boring, correct
> place to put secrets and per-environment config instead, and a reflex for catching the AI when
> it does the wrong thing.
---
## Prerequisites
- **Module 2 — Version Control as a Safety Net.** You need `.gitignore` and the habit of reading
`git diff` before you commit. Both are load-bearing here.
`git diff` before you commit. Both matter here.
- **Module 12 — Revert, Reset, and Recovery.** You learned that Git history is forever and that
secrets *don't belong in it* — this module is the practical follow-through on that promise.
- **Module 15 — Security Scanning for AI-Generated Code.** Secret scanning is the automated gate
@@ -28,7 +28,7 @@ You can attempt the lab with only Modules 12, but the *why* leans on 12, 15,
By the end of this module you can:
1. Explain why a secret in source code is a different and worse problem than a bug and why Git
1. Explain why a secret in source code is a different and worse problem than a bug, and why Git
makes it permanent.
2. Move a secret out of code and into the **environment** (an environment variable or a gitignored
`.env` file), and have the app read it back at run time.
@@ -43,29 +43,30 @@ By the end of this module you can:
## Key concepts
### A secret in source is not a bug it's a leak
### A secret in source is not a bug, it's a leak
A bug is a wrong behavior you can fix and move on from. A hardcoded secret is different: the moment
it's written to a file in a repo, you've started a countdown. Commit it and it's in your history
**forever** Module 12 was blunt about this: `git revert` writes a *new* commit undoing the
change, but the old commit, with the key in plain text, is still right there in the log for anyone
who clones the repo. Push it (Module 8) and it's now on a server, in every teammate's clone, and in
**forever**. Module 12 was blunt about this: `git revert` writes a *new* commit undoing the change,
but the old commit, with the key in plain text, is still right there in the log for anyone who
clones the repo. Push it (Module 8) and it's now on a server, in every teammate's clone, and in
every backup. "Delete the line and commit again" does nothing; the secret is in the snapshot, not
the current file.
So the only real fix after a leak is **rotation**: revoke the exposed key at the provider and issue
a new one, treating the old one as compromised. That's expensive and easy to forget, which is why
the entire discipline is built around *never writing the secret to a tracked file in the first
place.* Prevention is the whole game.
the whole discipline is built around one rule: *never write the secret to a tracked file in the
first place.* Prevention is the only cheap fix.
What counts as a secret: API keys and tokens, database passwords and connection strings, private
keys and certificates, signing/encryption keys, OAuth client secrets, webhook signing secrets. The
test is simple *if this string leaked, would someone have to scramble?* If yes, it's a secret and
test is simple. *If this string leaked, would someone have to scramble?* If yes, it's a secret and
it does not go in code.
### Config vs. secrets vs. code
Three things often get jumbled into source files. Pulling them apart is the whole mental model:
Three things often get jumbled into source files. Pulling them apart is the mental model for the
rest of this module:
| Kind | Example | Where it lives | Goes in Git? |
|------|---------|----------------|--------------|
@@ -75,8 +76,8 @@ Three things often get jumbled into source files. Pulling them apart is the whol
The dividing line that matters: **config and secrets are things that change between *where* the app
runs, not *what* the app does.** Your dev laptop, the staging server, and production all run the
same code they differ only in config (different URLs) and secrets (different keys). That
observation is the entire 12-factor idea below.
same code; they differ only in config (different URLs) and secrets (different keys). That
observation is what the 12-factor rule below is built on.
### The environment: where config and secrets actually go
@@ -95,7 +96,7 @@ TASKS_API_KEY="sk-live-..." python sync.py
$env:TASKS_API_KEY="sk-live-..."; python sync.py
```
Read it back in code and **fail loudly if it's missing**, because a silent empty string is worse
Read it back in code, and **fail loudly if it's missing**, because a silent empty string is worse
than a crash:
```python
@@ -106,14 +107,14 @@ if not api_key:
raise SystemExit("TASKS_API_KEY is not set. Copy .env.example to .env and fill it in.")
```
That's the whole pattern. The secret never appears in the file; the file only *asks the environment*
for it. Anyone reading the source learns *that a key is needed* but not *what the key is* which is
That's the pattern. The secret never appears in the file; the file only *asks the environment* for
it. Anyone reading the source learns *that a key is needed* but not *what the key is*, which is
exactly the property you want.
### `.env` files: the developer-friendly middle ground
Typing `TASKS_API_KEY=...` before every command gets old, and exported shell variables vanish when
you close the terminal. The conventional fix is a **`.env` file** a flat list of `KEY=value`
you close the terminal. The conventional fix is a **`.env` file**: a flat list of `KEY=value`
lines, sitting in your project, that gets loaded into the environment when the app starts:
```
@@ -139,8 +140,8 @@ Two non-negotiable rules come with it:
2. **Commit a template, not the secrets.** A `.env.example` (or `.env.template`) lists every
variable the app needs with **placeholder** values and no real secrets. *This* file you commit.
It's the documentation that tells a teammate or the next AI session reading the repo as memory
(Module 2) exactly what to supply:
It's the documentation that tells a teammate (or the next AI session reading the repo as memory,
Module 2) exactly what to supply:
```
# .env.example (committed)
@@ -149,13 +150,13 @@ Two non-negotiable rules come with it:
```
Loading a `.env` is usually one line via a small library (every major language has one). You can
also load it with a few lines of your own code and zero dependencies the lab shows the
also load it with a few lines of your own code and zero dependencies; the lab shows the
dependency-free version so it runs anywhere with just the language installed.
> **Naming, not values, is the contract.** Standardize the variable *names* across the team and
> commit them in the template. The values are local and secret; the names are shared and public.
> When the AI writes `os.environ["TASKS_API_KEY"]`, it should match what's in `.env.example`
> exactly a mismatch is the most common "works on my machine" failure in this whole area.
> exactly; a mismatch is the most common "works on my machine" failure in this whole area.
### 12-factor: config in the environment, one build everywhere
@@ -167,7 +168,7 @@ and factor III states it plainly: **store config in the environment.** The payof
> at run time as environment variables.
This is why it pairs so tightly with containers (Module 16). A container image is your immutable,
built-once artifact. You don't build a "staging image" and a "prod image" you build *one* image
built-once artifact. You don't build a "staging image" and a "prod image"; you build *one* image
and start it with different environment variables:
```bash
@@ -175,8 +176,8 @@ docker run -e APP_ENV=staging -e TASKS_API_KEY="$STAGING_KEY" tasks-app
docker run -e APP_ENV=prod -e TASKS_API_KEY="$PROD_KEY" tasks-app
```
Same image, different environment. That's the whole idea, and it's what makes the delivery pipeline
in Module 18 sane: promote one artifact through environments instead of rebuilding per stage.
Same image, different environment. That's what makes the delivery pipeline in Module 18 sane:
promote one artifact through environments instead of rebuilding per stage.
### Per-environment config: dev, staging, prod
@@ -206,7 +207,7 @@ backend_url = ENVIRONMENTS[app_env] # config selected by environment, not hard
```
The *non-secret* per-environment config (which URL goes with which env) is fine to keep in code
like this it's not sensitive and it's the same everywhere the code runs. Only the *secret values*
like this; it's not sensitive and it's the same everywhere the code runs. Only the *secret values*
and the *choice of which environment this process is* come from outside.
### Secret stores: when a file on disk isn't enough
@@ -222,8 +223,8 @@ reasons that show up fast in real operations:
A **secret manager** (also called a secrets store or vault, categorically) solves these. It's a
dedicated service that stores secrets encrypted at rest, hands them out only to authenticated
callers, logs every access, and supports rotation and fine-grained access policies. At run time your
app or the platform it runs on fetches the secret from the manager into memory instead of
reading a file. The categories you'll encounter:
app (or the platform it runs on) fetches the secret from the manager into memory instead of reading
a file. The categories you'll encounter:
- **Cloud-provider managers** — every major cloud has one, tightly integrated with that cloud's
identity system.
@@ -237,20 +238,20 @@ reading a file. The categories you'll encounter:
You don't need a manager for the lab or for a solo project. You need it the moment a secret has to
be available to *more than one machine you don't personally babysit*. The mental upgrade is the same
either way: **the app reads its secret from the environment; what populates the environment grows
up from a file to a service.** Your code doesn't change — that's the point of reading from the
up from a file to a service.** Your code doesn't change, which is the point of reading from the
environment all along.
---
## The AI angle
This module exists because of one specific, relentless AI failure mode: **AI loves to hardcode
This module exists because of one specific, recurring AI failure mode: **AI loves to hardcode
secrets.** Ask any coding assistant to "add authentication," "connect to the database," or "call
the API," and a large fraction of the time it will write the key, token, or password directly into
the source file often with a cheerful comment like `# your API key here`. It does this because
its training data is full of tutorials and quick examples that do exactly that, and because a
literal value is the path of least resistance to working code. The code *runs*, the demo *works*,
and a leak is now one `git commit` away.
the source file, often with a comment like `# your API key here`. It does this because its training
data is full of tutorials and quick examples that do exactly that, and because a literal value is
the path of least resistance to working code. The code *runs*, the demo *works*, and a leak is now
one `git commit` away.
This is the textbook case of the recurring course theme: **AI output that looks right and runs is
not the same as output that's safe.** A human who knows better still has to catch it, because the
@@ -258,17 +259,17 @@ model will keep offering it. Concretely:
- **Make "where did the secret go?" a review reflex.** Every time the AI touches auth, config, or a
network call, read the `git diff` (Module 2) and grep the change for anything that looks like a
key before you commit. The diff is where you catch it cheaply *before* it's in history.
key before you commit. The diff is where you catch it cheaply, *before* it's in history.
- **Tell the AI the pattern up front.** Put the rule in your committed instructions file (Module 5):
*"Never hardcode secrets. Read all keys and config from environment variables; add new ones to
`.env.example`."* A model given that house rule will usually write the `os.environ` version on the
first try. This is the prevention-by-config payoff Module 5 promised.
- **Let the AI do the refactor it's good at it.** The same model that hardcodes a key on the way
in is genuinely good at pulling it back out when you ask: "move every hardcoded secret and
- **Let the AI do the refactor; it's good at it.** The same model that hardcodes a key on the way
in is good at pulling it back out when you ask: "move every hardcoded secret and
environment-specific value into environment variables, fail loudly if they're missing, and update
`.env.example`." That's exactly the lab.
- **Secret scanning is the backstop, not the plan (Module 15).** A scanner in CI catches the key
you missed but by then it may already be in a commit. Treat a scanner hit as a *rotation event*,
you missed, but by then it may already be in a commit. Treat a scanner hit as a *rotation event*,
not a code-review comment. The goal of this module is that the scanner stays quiet because the
secret never reached the repo.
@@ -278,16 +279,17 @@ model will keep offering it. Concretely:
**Lab language:** Python + shell, on a new `sync` feature for the `tasks-app` from Module 1.
You'll take a file that hardcodes a secret the exact thing an AI hands you and refactor it so
the secret lives in the environment and the real values never enter Git. Then you'll make it select
config per environment.
You'll take a file that hardcodes a secret (the exact thing an AI hands you) and refactor it so the
secret lives in the environment and the real values never enter Git. As in every module past
Module 4, you direct the agent to do the git and setup work and then verify the result; you don't
type the commands by hand. Then you'll make it select config per environment.
**You'll need:**
- The `tasks-app` folder from Modules 12 (a Git repo with a `.gitignore`).
- Python 3.10+ and a terminal.
- The starter files in this module's `lab/starter/`: `sync.py` (the before) and `.env.example`.
- Your AI assistant (browser or editor-integrated — by now, your choice).
- Claude Code in your terminal (`claude --version` to confirm it's installed; sub your own agent).
### Part A — See the smell
@@ -299,14 +301,22 @@ config per environment.
python sync.py
```
It prints a simulated request including `Authorization: Bearer sk-live-...`. Open `sync.py` and
It prints a simulated request, including `Authorization: Bearer sk-live-...`. Open `sync.py` and
find the two hardcoded lines: `API_KEY` and `BACKEND_URL`. **This is the AI default.** Picture
this getting committed and pushed: the key is now in history forever (Module 12) and a secret
scanner (Module 15) would light up if you were lucky enough to have one.
scanner (Module 15) would light up, if you were lucky enough to have one.
### Part B — Gitignore the secret *first*
2. Before any real secret exists, close the door. Add these lines to your `.gitignore`:
2. Before any real secret exists, close the door. Tell Claude Code (sub your own agent) to set up
the ignore rules:
> *"Add rules to `.gitignore` that ignore `.env` and any `.env.*` file but keep tracking
> `.env.example`, then create a real `.env` with `APP_ENV=dev` and a throwaway
> `TASKS_API_KEY=sk-live-test-0000`. Explain the `!.env.example` negation line."*
The agent edits `.gitignore` and writes the file; you supplied the *ordering* that matters
(ignore the secret before the secret exists). The rules should land like this:
```gitignore
# secrets and local config — never commit
@@ -315,23 +325,23 @@ config per environment.
!.env.example
```
3. Confirm Git will ignore a real `.env` but still track the template:
3. Now **verify** the door actually closed. Read `git status` yourself:
```bash
printf 'APP_ENV=dev\nTASKS_API_KEY=sk-live-test-0000\n' > .env
git status # .env must NOT appear; .env.example and your .gitignore change SHOULD
```
If `.env` shows up in `git status`, stop and fix the ignore rule before going further. This is
the step that prevents the leak.
If `.env` shows up in `git status`, the ignore rule is wrong; have the agent fix it before going
further. This verification is the step that prevents the leak.
### Part C — Refactor the secret into the environment
4. Now move the secret and the environment-specific URL out of the code. Ask your AI:
4. Now move the secret and the environment-specific URL out of the code. Ask Claude Code (sub your
own agent):
> *"Refactor `sync.py` so it reads `TASKS_API_KEY` and `APP_ENV` from environment variables
> instead of hardcoding them. Pick the backend URL from `APP_ENV` (dev/staging/prod). Fail loudly
> with a clear message if `TASKS_API_KEY` is missing. Don't add any third-party dependency load
> with a clear message if `TASKS_API_KEY` is missing. Don't add any third-party dependency; load
> the `.env` file with a few lines of plain Python, and make sure the loader does **not**
> overwrite a variable that's already set in the environment, so a value passed on the command
> line still wins."*
@@ -376,7 +386,7 @@ config per environment.
**Why `setdefault` and not plain assignment?** The loader uses `os.environ.setdefault(key, value)`,
which sets a variable *only if it isn't already set*. That precedence is load-bearing: a value the
environment already supplies like an `APP_ENV` you pass on the command line wins over the
environment already supplies (like an `APP_ENV` you pass on the command line) wins over the
`.env` file. A loader that writes `os.environ[key] = value` instead **clobbers** anything already
there, so the file silently overrides your command line and Part D's override demo does nothing.
This matches the real-world dotenv default (`override=False`): the file fills in gaps, it doesn't
@@ -407,28 +417,31 @@ config per environment.
Watch the backend URL change with `APP_ENV` while the source never does. That's config in the
environment. **If the URL *doesn't* change, your loader is clobbering variables that were already
set** it's using `os.environ[key] = value` where it needs `os.environ.setdefault(...)` (see
set:** it's using `os.environ[key] = value` where it needs `os.environ.setdefault(...)` (see
Part C). Fix the loader so the command line wins, and the override takes effect.
### Part E — Commit, and verify the secret didn't tag along
7. Stage and **read the diff before committing** — the review reflex from the AI angle:
7. Have the agent commit the refactor, then **read the diff yourself before you accept it** (the
review reflex from the AI angle). Tell Claude Code (sub your own agent):
> *"Stage and commit the refactor with a message like 'Read secrets and per-env config from the
> environment, not source'. Include the refactored `sync.py`, the `.gitignore` change, and
> `.env.example`; do NOT stage the real `.env`."*
Now verify the agent staged the right things. Read the staged diff and the status yourself:
```bash
git add -A
git diff --cached # the refactored sync.py + .gitignore + .env.example
```
Confirm the diff contains the *template* and the *code that reads the environment*, and **not**
the real key or your `.env`. Then:
```bash
git commit -m "Read secrets and per-env config from the environment, not source"
git status # clean; .env remains untracked
```
You've now done the exact refactor that turns the AI's default mistake into the correct pattern —
and left behind a `.env.example` so the next person (or agent) knows what to supply.
The diff must contain the *template* and the *code that reads the environment*, and **not** the
real key or your `.env`. If the real `.env` slipped into the commit, that's a leak in the making;
have the agent unstage it and recommit before you move on.
You've now done the exact refactor that turns the AI's default mistake into the correct pattern, and
left behind a `.env.example` so the next person (or agent) knows what to supply.
---
@@ -436,16 +449,16 @@ and left behind a `.env.example` so the next person (or agent) knows what to sup
- **`.env` is not encryption.** A `.env` file is plaintext on disk. Gitignoring it keeps it out of
*Git*, not out of reach of anything with access to your machine. It's the right tool for local
dev and the wrong tool for a shared server — that's where a secret manager earns its place.
dev and the wrong tool for a shared server, which is where a secret manager earns its place.
- **Environment variables leak in their own ways.** They can show up in process listings, crash
dumps, log lines that print the whole environment, and child processes that inherit them. Reading
from the environment is far better than hardcoding, but it's not a force field don't log the
from the environment is far better than hardcoding, but it's not a force field: don't log the
environment, and scrub secrets from error reports.
- **A committed template can still leak by accident.** The whole scheme depends on `.env.example`
staying free of real values. It's easy to "just fill it in to test" and commit it. Keep the
- **A committed template can still leak by accident.** The scheme only holds if `.env.example`
stays free of real values. It's easy to "just fill it in to test" and commit it. Keep the
placeholder discipline, and lean on the Module 15 scanner as the backstop for the day you slip.
- **The damage may already be done.** If a secret was *ever* committed even in a commit you later
reverted assume it's compromised and **rotate it**. Removing it from current files does not
- **The damage may already be done.** If a secret was *ever* committed, even in a commit you later
reverted, assume it's compromised and **rotate it**. Removing it from current files does not
remove it from history. Scrubbing history is possible but disruptive (and Module 12 warned you
about rewriting shared history); rotation is the reliable fix.
- **Managed secrets aren't automatically safe.** A secret manager with over-broad access policies,
@@ -459,18 +472,18 @@ and left behind a `.env.example` so the next person (or agent) knows what to sup
**You're done when:**
- `sync.py` runs entirely from the environment, and `grep "sk-live" sync.py` prints nothing.
- A real `.env` exists, contains your secret, and does **not** appear in `git status` while
- A real `.env` exists, contains your secret, and does **not** appear in `git status`, while
`.env.example` is tracked.
- `APP_ENV=staging python sync.py` and the default run hit different backend URLs with **zero**
source edits between them.
- You can state, in one sentence, why deleting a committed secret and re-committing does not fix the
leak and what the actual fix is (rotation).
leak, and what the actual fix is (rotation).
- You've added a "never hardcode secrets; read from the environment" rule to your committed
instructions file (Module 5), so the AI stops reintroducing the problem.
When the AI hands you a hardcoded key and your first instinct is "that goes in the environment, and
the diff has to prove it didn't reach Git," the reflex is installed. Module 18 takes this artifact
built once, configured per environment and ships it.
the diff has to prove it didn't reach Git," the reflex is installed. Module 18 takes this artifact
(built once, configured per environment) and ships it.
---