A nightly job asks Claude Code to fix typos. It “kept stopping to ask for permission”, so someone
added --dangerously-skip-permissions, and for weeks nothing bad happened. Then the model decided
that a directory of unpublished drafts was clutter, deleted it, and opened the file holding the
publishing token while it was looking around. Nothing in Claude Code malfunctioned: it did what the
job told it it was allowed to do.
The fix is not “take the flag out”. Take the flag out and a user setting still skips every check;
switch to a strict mode and put the allow rule in the project’s settings file and a headless run
ignores it; forbid rm and the model has /bin/rm, find -delete, git rm and the Write tool.
There are really four questions: what mode decides a call no rule mentions, which tools the
session has at all, where an allow rule is honoured, and what a tool result carries to the
model — plus, first, getting the drafts back.
dontAsk is the mode for
unattended jobs.--tools) and refusing a call (a deny rule), and
what the model sees in each case.claude -p
run honours in a repository nobody has trusted.Read deny rule stops, and which it does not.When the model asks for a tool call, Claude Code decides before running anything. Rules are
evaluated deny first, then ask, then allow, across every settings level, and a deny anywhere
cannot be allowed anywhere else — not even by --allowedTools on the command line. A call that
matches no rule is decided by the permission mode:
| mode | a call no rule mentions |
|---|---|
default (Manual) |
reads in the working directory run; anything else prompts |
acceptEdits |
file edits and simple filesystem commands (mkdir, touch, mv, cp) run; the rest prompts |
plan |
nothing that changes anything runs |
dontAsk |
anything that would prompt is denied |
bypassPermissions |
everything runs, except ask and deny rules and a few protected paths |
In -p there is nobody to answer a prompt, so a prompt is a denial there too; dontAsk makes that
explicit and tells the model so. The denied call comes back to the model as a tool error, and the
run’s JSON result lists it in permission_denials — the single most useful field when a job “does
nothing”.
--dangerously-skip-permissions is bypassPermissions on the command line. The documentation
reserves it for isolated containers and VMs, and that is the right reading: it is for a machine you
are happy for the model to wreck.
A permission mode (permissions.defaultMode) and rules can be set in any of these, highest
precedence first:
/etc/claude-code/managed-settings.json on Linux);--permission-mode, --allowedTools, --settings);.claude/settings.local.json in the project;.claude/settings.json in the project, usually committed;~/.claude/settings.json, the user’s.Scalar keys such as defaultMode are taken from the highest level that sets them; lists such as
permissions.allow and permissions.deny merge across levels. So removing a flag from a
script does not remove a defaultMode two levels down, which is exactly what happens in this lab.
Interactive Claude Code asks whether you trust a folder the first time you open it. A headless run
cannot ask, and a repository can ship any .claude/settings.json it likes — including one that
allows Bash. Claude Code 2.1.270 resolves this by ignoring allow rules from the shared project
settings file when claude -p runs in a workspace that has not been trusted, and saying so on
stderr:
Ignoring 1 permissions.allow entry from .claude/settings.json: this workspace has not been trusted.
Deny rules from the same file still apply: a repository may restrict itself, not unlock itself.
The same allow rule works from --allowedTools, --settings <file>, the user settings or
.claude/settings.local.json. In a CI job, where nobody will ever accept the trust dialog, put what
the job needs on its command line, next to the prompt it applies to. (Hooks, CLAUDE.md, subagents
and .mcp.json servers do load in an untrusted -p run; allow rules are the exception.)
--tools "Read,Edit" limits the built-in tools the session has. A tool outside the list is not
offered to the model, and a call to it fails before any permission check:
Error: No such tool available: Bash. Bash is disabled for this session, in subagents as well as here.
A deny rule leaves the tool in place and refuses particular calls. Rules for Bash match the command
text, and they are not a security boundary: Bash(rm *) does not match /bin/rm -rf drafts,
find drafts -delete, python3 -c "shutil.rmtree('drafts')" or git rm -r drafts. In dontAsk
those still fail — not because of the rule but because nothing allows them. The robust shape for a
job is therefore: a strict mode, the fewest tools that do the task, and narrow allow rules for the
calls the task makes.
A tool result is part of the next request. If the Read tool opens .env, the token is in the
conversation, in Anthropic’s request logs for that run, and in the session transcript Claude Code
writes to ~/.claude/projects/<project>/<session>.jsonl unless the run passes
--no-session-persistence.
A Read deny rule for .env covers more than the Read tool, and less than it seems:
| the model runs | with deny: ["Read(./.env)"] in dontAsk |
|---|---|
Read /home/learner/handbook/.env |
refused |
Grep for TOKEN in the whole directory |
the file is skipped: “No matches found” |
Grep in .env itself |
refused |
Bash cat .env, head -1 .env, tail .env, sort .env, cat /abs/path/.env |
refused |
Bash grep -r TOKEN . |
prints the token |
Bash cat .e* |
prints the token |
Read-only commands run without asking, and the rule catches one only when the command text names the denied path. The lesson is the same as for deleting: if the job does not need a shell, do not give it one. And a token that must exist should not live in the directory an agent works in.
Replayed in the lab container (Claude Code 2.1.270, the scripted model). Start with what changed. Nobody has committed since the run, so git knows exactly what it destroyed:
$ cd ~/handbook && git log --oneline && git status --short
8648d84 Handbook as of August
M docs/getting-started.md
D drafts/2026-q4-roadmap.md
D drafts/incident-2026-08-30.md
D drafts/pricing-v2.md
The deletions are in the working tree only. git restore takes the files from HEAD; with
--staged --worktree it also resets the index, in case anything staged the deletion (the grader
tries git rm):
$ git restore --source=HEAD --staged --worktree -- drafts docs
$ ls drafts
2026-q4-roadmap.md incident-2026-08-30.md pricing-v2.md
The job’s log is its JSON result. permission_denials is empty — nothing was refused, because
nothing was checked:
$ jq '{result, num_turns, permission_denials}' ~/tidy-handbook.log
{
"result": "Tidied the handbook: fixed a typo in docs/getting-started.md and removed drafts/.",
"num_turns": 5,
"permission_denials": []
}
The obvious fix is to delete the flag. The run behaves exactly as before — five turns, no denials,
the drafts gone again — because ~/.claude/settings.json sets the same mode a second time:
$ sed -i '/dangerously-skip-permissions/d' ~/bin/tidy-handbook && ~/bin/tidy-handbook
$ tail -n1 ~/tidy-handbook.log | jq -c '{num_turns, permission_denials}'
{"num_turns":5,"permission_denials":[]}
$ cat ~/.claude/settings.json
{
"permissions": {
"defaultMode": "bypassPermissions"
}
}
Next attempt: restore again, empty the user settings, run the job with --permission-mode dontAsk,
and write the policy where the team will review it — the project’s .claude/settings.json,
allowing edits in docs/ and denying .env. The drafts survive, and so do the typos:
$ ~/bin/tidy-handbook
$ head -n -1 ~/tidy-handbook.log
Ignoring 1 permissions.allow entry from .claude/settings.json: this workspace has not been trusted. …
$ tail -n1 ~/tidy-handbook.log | jq -c '[.permission_denials[] | {tool_name, tool_input}]'
[{"tool_name":"Edit","tool_input":{"file_path":"/home/learner/handbook/docs/getting-started.md",…}},
{"tool_name":"Bash","tool_input":{"command":"rm -rf drafts","description":"Remove clutter"}},
{"tool_name":"Read","tool_input":{"file_path":"/home/learner/handbook/.env"}}]
All three calls were refused: the rm and the .env read as intended, and the edit because its allow
rule was ignored. The stderr line says why. The allow belongs on the job’s command line; the deny
rules can stay in the project file, where they are honoured and reviewed. The job becomes:
cd "$HOME/handbook" || exit 1
claude -p "Fix the typos in docs/." \
--permission-mode dontAsk \
--tools "Read,Edit,Glob,Grep" \
--allowedTools "Edit(./docs/**)" \
--max-turns 10 \
--output-format json > "$HOME/tidy-handbook.log" 2>&1
with .claude/settings.json denying Read(./.env), Read(./.env.*) and Edit(./drafts/**), and
~/.claude/settings.json reduced to {}. The model replays last night’s plan once more:
$ ~/bin/tidy-handbook
$ tail -n1 ~/tidy-handbook.log | jq -c '{num_turns, denials: [.permission_denials[] | .tool_name]}'
{"num_turns":5,"denials":["Read"]}
$ git status --short
M docs/getting-started.md
?? .claude/
The typo is fixed, the rm failed as “No such tool available: Bash” (it is not a denial — Bash is
not in the session), and the read of .env was refused. The grader then tries harder than last
night’s model did:
PASS 02_the_job_fixes_typos_and_deletes_nothing: The job fixed a typo in docs/ and refused all 7
attempts to delete or overwrite.
PASS 03_the_token_never_reaches_the_model: None of the ways the model tried to read the token got it.
Read /home/learner/handbook/.env: File is in a directory that is denied by your permission settings.
Bash cat .env: Error: No such tool available: Bash. …
Grep TOKEN: No matches found
One thing the grader cannot undo: the transcript of last night’s run is still on disk with the token in it, and so is the run where only the flag was removed.
$ grep -l hbk_live ~/.claude/projects/*/*.jsonl | wc -l
2
In real life the token is now compromised — it was sent to a model provider — and has to be rotated
at the service that issued it. Delete those transcripts, and give the job --no-session-persistence
if nobody reads its sessions.
Removing the flag and calling it done. A defaultMode in any settings file still applies; the
job’s behaviour did not change at all. Look at permission_denials after every change: an empty
list on a run that tried to delete something means nothing is being checked.
Putting the allow rules in the project’s .claude/settings.json for a CI job. Nobody ever
trusts a CI checkout, so claude -p ignores them and the job quietly does nothing. The warning is on
stderr — which the job redirects into its log, so read it.
Answering with acceptEdits. Edits run without asking — including the Write tool emptying a
draft and rewriting README.md. It is a mode for a person watching, not for a job.
Denying Bash(rm *) and keeping Bash. The model has /bin/rm, find -delete, python3,
git rm and a dozen more. Bash rules match text, not intent.
Denying Read(./.env) and keeping Bash. grep -r TOKEN . and cat .e* still print it.
Exporting the token into the job’s environment so the model “doesn’t need the file”. Anything
the job’s process can see, env shows the model the moment Bash is available.
Restoring with git checkout -- drafts after something staged the deletion. checkout -- takes
files from the index; if git rm already removed them from the index, there is nothing there.
git restore --source=HEAD --staged --worktree restores both from the commit.
Deleting the job. The typo fixes were the point. The goal is a job that can do its one task and nothing else.
# what a run did and what was refused
jq '{result, num_turns, is_error, permission_denials}' run.json
claude -p "…" --output-format json 2>run.err >run.json # keep stderr: trust and config warnings
# the modes, strict to loose
claude -p "…" --permission-mode dontAsk # unattended: anything not allowed is refused
claude -p "…" --permission-mode acceptEdits # edits run without asking — not for jobs
claude -p "…" --dangerously-skip-permissions # = bypassPermissions: disposable machines only
# fewest tools, narrowest allow, caps
claude -p "…" --tools "Read,Edit,Glob,Grep" \
--allowedTools "Edit(./docs/**)" --max-turns 10 --no-session-persistence
# where modes and rules come from (highest first)
cat /etc/claude-code/managed-settings.json # managed
# command line flags / --settings file
cat .claude/settings.local.json # local project (honoured untrusted)
cat .claude/settings.json # shared project: allow ignored by untrusted -p
cat ~/.claude/settings.json # user
grep -rn 'bypassPermissions\|dangerously' ~/.claude .claude ~/bin
# a deny rule for a secret — and why it is not enough with Bash
{ "permissions": { "deny": ["Read(./.env)", "Read(./.env.*)"] } }
# stops: Read, Grep on the file, cat/head/tail .env does not stop: grep -r ., cat .e*
# get deleted files back from the last commit
git status --short
git restore --source=HEAD --staged --worktree -- drafts
# what past runs sent the model
ls ~/.claude/projects/*/ # one .jsonl transcript per session
grep -l 'SECRET_PATTERN' ~/.claude/projects/*/*.jsonl
Where this lab’s hints send you for reading, beyond this journal. In the TUI, h shows a
hint’s reading and l opens a journal section; the lab’s page on the site lists the same.
claude-code) — What the model may doclaude-code) — Guards, helpers and outside dataclaude-code) — What a session loadsDocumentation:
The whole subject, end to end: the topic journal Claude Code in a job (claude-code) —
the mechanism, the symptoms and their causes, exercises, and sources.
A job runs claude -p with --permission-mode dontAsk and no allow rules. The model calls Edit on
a file in the working directory. What happens, and where do you see it?
The edit is refused, because in dontAsk anything that would prompt is denied. The model gets a tool error and carries on; the JSON result lists the call in
permission_denials.
Someone removes --dangerously-skip-permissions from a job and the model still deletes files
without a single denial. Name the likeliest cause.
A
permissions.defaultModeofbypassPermissionsin a settings file — here the user’s~/.claude/settings.json. Scalar settings come from the highest level that sets them, and the command line no longer sets one.
Why are allow rules in a committed .claude/settings.json ignored by claude -p in a fresh CI
checkout, while its deny rules still apply?
The workspace has not been trusted, and a repository must not be able to grant itself permissions; restricting itself is harmless. Claude Code 2.1.270 ignores project allow entries there and warns on stderr. Put the allow on the command line (
--allowedTools) or in--settings.
What is the difference, as the model experiences it, between --tools "Read,Edit" and a deny rule
Bash?
With
--tools, Bash is not in the session: it is not offered, and a call fails as “No such tool available”. With a deny rule the tool is offered and each call is refused as a permission denial. Both stop the call; removing the tool also keeps it out of the model’s plan.
A job keeps Bash and denies Bash(rm *). Give three commands that still delete a directory.
/bin/rm -rf drafts,find drafts -delete,git rm -r drafts(orpython3 -c "import shutil; shutil.rmtree('drafts')"). Bash rules match command text; they are not a security boundary. In dontAsk mode with no allow for them they are refused anyway.
With deny: ["Read(./.env)"] and Bash available, which of cat .env, grep -r TOKEN . and the
Read tool on .env show the model the token?
Only
grep -r TOKEN .. Read is refused, andcat .envnames the denied path so it is refused; a read-only command that does not name it runs without asking.
Once a model has read a secret, where else does it exist besides the provider’s side of the request, and what should happen to it?
In the session transcript under
~/.claude/projects/<project>/<session>.jsonl(unless the run used--no-session-persistence) and in any log of the job’s output. The secret is compromised: rotate it where it was issued, then delete the transcripts.
The drafts were removed with git rm -r drafts and not committed. Why does
git checkout -- drafts not bring them back, and what does?
checkout -- <path>copies from the index, andgit rmalready removed them from it.git restore --source=HEAD --staged --worktree -- draftsrestores the index and the working tree from the last commit.