You open an empty folder, type "build me an app", and for about two days it's magic.
Then week two arrives. The agent drops a database call straight into a component, because nothing ever told it not to. A folder you needed gets renamed. Somewhere in there it explains, with total confidence, a decision you never made. You start every session re-explaining the same things, and each explanation lives exactly as long as that one conversation.
Here's the part that took me too long to internalize: the agent doesn't remember yesterday. Not "sort of remembers". Doesn't. Every session starts from nothing, and whatever isn't written into a file simply didn't happen.
So before a line of code exists, five files go in. It's about fifteen minutes, and it's the difference between a project that accumulates and one that resets every morning.
CLAUDE.md
- the thing it reads first This is the file the agent opens at the start of every session. Treat it as working memory - the agent has none of its own, so this file is all there is. Documentation for a future teammate is a different job, and it can wait.
What actually earns its place in there:
Keep it short enough that you'd actually re-read it. Mine drifts toward a hundred lines and I trim it back.
.claude/settings.json
- the things that aren't up for discussion Rules in prose get weighed against everything else in the context window. A deny rule doesn't get weighed. It just fails.
Use prose for judgement - naming, style, when to stop and ask. Use the deny list for the handful of things that are irreversible:
{
"permissions": {
"deny": [
"Bash(rm -rf*)",
"Bash(rm -fr*)",
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Read(**/.env)",
"Read(**/.env.*)"
]
}
}
Two things I got wrong here and would rather you didn't.
Spell the same command every way it can be spelled. These are prefix matches, not intent detection. rm -rf
and rm -fr
are the same command to you and two different strings to the matcher. Same with git push --force
versus git push origin +main
- the
+
is a force push wearing a different hat, and a rule written for --force
never sees it coming.
**Use Read(**/.env), not `
Read(./.env).** The
./version anchors to one directory. The
**/version follows gitignore semantics and catches the file at any depth. If your project ever grows a
services/api/.env`, the first version protects nothing and looks like it does.
.env.example
- so it doesn't need the real one The agent needs to know which variables exist. It doesn't need their values. An example file with the names and empty values answers the question completely, and the real file stays denied.
DATABASE_URL=
STRIPE_SECRET_KEY=
Worth knowing before you ship anything to a browser: anything prefixed NEXT_PUBLIC_
or VITE_
gets compiled into the page and is visible to every visitor. That happens at build time, so there's no fixing it afterwards.
.gitignore
- before the first commit, not after Obvious file, easy to postpone, expensive to postpone.
A key that reaches git history doesn't leave when you delete the line. It stays in the history, and public repos get scanned by bots continuously. Rotating a key you know leaked is annoying. The other kind is worse.
.pre-commit-config.yaml
- a scanner that runs whether you remember or not
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
- id: gitleaks
Then once, after git init
:
pip install pre-commit
pre-commit install
That second command is the whole thing. Without it the YAML sits there looking responsible and scanning nothing. I'd bet a lot of repos have exactly that.
And when you test it - don't use AKIAIOSFODNN7EXAMPLE
. It's the fake AWS key from the docs, gitleaks allowlists it deliberately, your commit sails through, and you conclude the hook is broken or, worse, that it's working. Use any invented key in a realistic shape.
Here's the one I'd most want to know as a beginner, because nothing warns you.
CLAUDE.md
is inherited up the tree. settings.json
is not.
Your rules file gets picked up from parent folders. The deny list doesn't travel at all - it's read only from the folder you actually started the session in, and there's no falling back to the parent.
I found this the boring way, reading docs for something else. I keep one project across three folders: notes and plans in one, a website in another, an app in a third. Guess which folder had the strongest deny list, a sandbox config and a working hook on top. The one with the text files. Meanwhile both folders holding real code - live database, deploy, signing key - had nothing at all. Not weak protection. None.
It had been that way for weeks and I'd have told you the project was locked down, because I'd seen the config with my own eyes. In the wrong folder.
So: the protection goes in every folder you actually open a session in. If you work in a monorepo and start sessions in subfolders, that's every subfolder, not the root.
None of it reviews your code, saves a machine that's already compromised, or makes the agent's judgement trustworthy. It removes the failure modes that are common and permanent, and it does that before there's anything to lose.
The rest is ordinary engineering discipline, same as it ever was.
I keep these files in a repo so I don't retype them: github.com/mikobuilds/claude-code-security-checklist. MIT, take what's useful.
If you do this differently - especially if you've got a deny rule that turned out to be theatre - I want to hear it.