DeepDojo.AIPublished 2026-11-13Updated 2026-11-13中文

Why Claude Code's "Read-Only" Command Edits Files

Claude CodeAI Coding

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:

GoalUse
These calls don't need approval each timeallowed-tools
This skill can't use certain tools while it runsThe skill's disallowed-tools field
Block a kind of action in every taskDeny rules in your permission settings
Tell the model the boundaries of this taskThe 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 status and git 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" high

An 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 ruleMeaning
mcp__projectAllow the whole MCP server
mcp__project__*Allow all tools on that server
mcp__project__get_itemAllow 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:

SymptomFix
It reads a diff but has a full shell, or reads docs but can commit and pushTighten tool permissions to the actual work
The template contains real tokens, keys, passwords, or private service credentialsUse 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.