DeepDojo.AIPublished 2026-11-13Updated 2026-11-13中文
Why Claude Code's "Read-Only" Command Edits Files
When you write a custom Claude Code command, one request comes up all the time: this command should look, not touch. A code review command, say, should read the diff, give its opinion, and stop there.
So people list only Read and Grep in allowed-tools and add "please only read, don't change code" to the body. Then they run it, and it edits files anyway.
The problem is what that field actually does. allowed-tools is a shortcut that skips the approval prompt for the tools it lists; it pre-approves them and switches nothing else off. If the session already allows automatic edits, an Edit request from the model still lands on disk, and the line about being read-only is just a reminder.
Two similar misunderstandings come up just as often. The dynamic context runs a cleanup command while the body says "confirm before acting," yet the cleanup runs before the model ever reads that sentence. A command named /push-all makes people assume every file in the working tree may go upstream.
All three have the same fix: set three things separately.
- Who is allowed to start the procedure.
- Which calls can skip human approval.
- Which risky actions are blocked outright.
Each of the three has its own switch, and a command that pushes or deploys also needs a few fixed checks on every run.
01 allowed-tools only skips the prompt
The Claude Code skills documentation is explicit: allowed-tools lets the listed tools run without asking during the turn that invokes the skill, and the grant clears when you send your next message. It doesn't restrict which tools are available. Every tool stays callable, and tools that aren't listed still follow your permission settings.
To make a command read-only by mechanism, use settings that remove tools:
| Goal | Use |
|---|---|
| These calls don't need approval each time | allowed-tools |
| This skill can't use certain tools while it runs | The skill's disallowed-tools field |
| Block a kind of action in every task | Deny rules in your permission settings |
| Tell the model the boundaries of this task | The prompt body |
allowed-tools handles "skip approval." disallowed-tools and global deny rules handle "actually block." The prompt body tells the model what this task should do within what it's allowed. Writing "read-only" in the body does nothing to shut off writes.
One more point people miss: project skills can grant themselves permissions. The docs warn that a skill checked into a repository can give itself broad tool access through allowed-tools, so review those skills before running Claude Code in an unfamiliar repo.
02 disable-model-invocation decides who starts it
When deployment, pushing to a remote, bulk deletion, or permission changes are involved, keep the start switch in your own hands first. disable-model-invocation: true stops Claude from loading the skill on its own; it only runs when a person types the slash command.
Example:
---
name: deploy
description: Deploy the current application to production
disable-model-invocation: true
allowed-tools: Bash(npm test *) Bash(npm run build *) Bash(git status *)
---
# Deploy
Before deployment:
1. Check git status.
2. Run tests.
3. Build the application.
4. Show a deployment summary.
5. Ask for explicit confirmation before any irreversible step.This example only defines the pre-deploy checks; it contains no actual release command. A real deployment still needs a target environment, release steps, and a recovery plan. Starting a skill by hand doesn't confirm every production action either.
Letting the model run local Git commands is a different grant from letting it push to a remote. A person typing the command still has to check the actual state of things. Once the start switch, the approval shortcut, and the real block are kept apart, you can spot which layer failed the moment a call goes wrong.
03 Injected commands run before the model reads the body
Dynamic context, !`command`, is convenient, but it runs on a completely different timeline from the body. Here's a bad example:
## Cleanup
- !`rm -rf build/ && npm run build`Reading the Markdown top to bottom, you might expect the "confirm with the user first" line below it to stop that deletion.
The actual execution order is the reverse. Dynamic context runs in the shell while the prompt is being assembled. The model only sees your "confirm first" after those commands have finished and their output is in.
Whether the command actually runs depends on your permissions. The official docs say each injected command is checked against your permission rules first. A deny rule match aborts the invocation; outside auto mode, any result other than "allow" aborts it too. Flip that around: once you've approved rm in allowed-tools or an allow rule, the deletion completes before you see any reply.
So get the order right:
- Only read-only state collection, such as
git statusandgit diff, goes in the injection area. - Cleanup, build, move, or anything else with side effects goes into the task steps in the body, to run after the model has analyzed the situation and you've approved.
- Approve side-effect commands only for the steps that need them, and keep them out of the injection area.
04 MCP prompts carry no tool permissions
An MCP server can also expose prompts as commands, usually as /mcp__<server-name>__<prompt-name> [arguments]:
/mcp__project__list_tasks
/mcp__project__review_item 456
/mcp__tracker__create_issue "Bug title" highAn MCP prompt supplies prompt text. It may lead Claude to call tools, but it isn't a tool and grants no tool permissions. These rules control MCP tool calls:
| Permission rule | Meaning |
|---|---|
mcp__project | Allow the whole MCP server |
mcp__project__* | Allow all tools on that server |
mcp__project__get_item | Allow one specific tool |
Before a command calls an external tool, confirm which tools it actually needs, then grant the minimum. That gets the job done and limits the damage a mistake can do. Plugin commands work the same way: keep the /plugin-name:command-name namespace in team docs so everyone can see where a command comes from.
05 What a one-click push command should check every time
Among common commands, /push-all deserves extra thought: its result lands in a remote repository, so the wrong file or branch has consequences beyond your machine. Analyze the changes, look for sensitive and large files, confirm the branch, then show a summary of this run. The user confirms a visible set of changes; the command makes sure those checks happen every time.
A safe /push-all checks at least:
- Sensitive files such as
.env*,*.key,*.pem,credentials.json,secrets.yaml. - Whether real values appear for API keys, secrets, or tokens; placeholders are fine, real keys get stopped.
- Files over 10MB that aren't under large-file management.
- Build or cache output accidentally included, such as
node_modules/,dist/,build/,__pycache__/,.venv/. - Whether you're on a protected branch.
- Whether there are merge conflicts.
If you can't yet tell which changes belong to this piece of work, stop before staging. Selective or interactive staging helps a person see the scope, and the user confirms it.
Pushing a temporary file with secrets to a public repository is the classic accident with "fully automatic" commands. The more a command promises one-click results, the more it needs to run its checks every time. A command named push-all is still only authorized to push the set of files you've seen and confirmed.
After writing it, check the command against two symptoms:
| Symptom | Fix |
|---|---|
| It reads a diff but has a full shell, or reads docs but can commit and push | Tighten tool permissions to the actual work |
| The template contains real tokens, keys, passwords, or private service credentials | Use a controlled credential mechanism; keep secrets out of files |
For the basics of writing commands, arguments, and tracing the execution chain, see how to write a custom Claude Code slash command. When you need to genuinely stop a risky command before it runs, that's a job for hooks; continue with picking the right hook event.
Back to that "read-only" command: disable-model-invocation holds the start switch, allowed-tools handles the approval shortcut, and disallowed-tools or deny rules shut off writes. Set each one on its own, and the command behaves the way you intended.