Skip to content

Allowing the commands the task needs

In this lesson we give a coding agent one small task twice more, in the same to-do fixture as the last lesson. The first run has nothing pre-approved, and we write down every prompt. Before the second run we turn that list of prompts into a short file of rules, one line per command the task needs, and the run goes through with almost no prompts. Then we read the tally again and delete every rule that answered no prompt. What is left is an allowlist you can read in one glance and check into a repository.

The rule syntax and the matching here are Claude Code’s, checked against the vendor’s documentation on the date at the end of the lesson [1]. Other coding agents have allowlists with other spellings. The method transfers, and the exact syntax may not.

The last lesson chose a mode for the whole session. This lesson keeps the mode fixed at Manual, where every edit and every command outside the built-in read-only set stops for you, and changes the other half of the setting. Allowlists are the rules that say which specific tools, commands and paths may run without a prompt, and deny rules say which may never run. In Claude Code they are three lists in a settings file, allow, ask and deny, each holding rules of the form Tool or Tool(specifier). Claude Code checks a tool call against deny first, then ask, then allow, and the first match decides. A narrow allow rule never overrides a broader deny rule, whichever came first in the file [1].

Make a fresh copy of the fixture directory, site/examples/coding-with-agents/first-session/fixture-repo/, outside the course repository, change into it, and give it a first commit, as you did in the last lesson. If you still have that copy, reset it with git checkout -- . and use it. Either way the copy is its own git repository, so no rule saved in another project’s settings file applies here.

Terminal window
git init -q && git add -A && git commit -q -m "fixture"

Start the agent with claude --permission-mode default, type /permissions, and look at the dialog. It lists every permission rule in force and the settings file each one came from [1]. In a fresh copy the only rules listed, if any, come from your user settings file, ~/.claude/settings.json, which applies in every project. Note any rule there for python3 or Edit: it removes a prompt from run one, and the tally has to account for it. The rest of this lesson assumes there is none. Close the dialog and send this brief.

Add a `clear` command to todo.py that removes every item marked done and
prints how many it removed. Add one test for it to test_todo.py. Then run
`python3 -m unittest -q`. One test, test_done_marks_the_numbered_item,
already fails and is not your task. Leave it. Do not edit todos.json.

Keep a tally with one line per prompt: the file it asked to edit, or the exact command it asked to run. Approve each one after you have read it, and choose the plain “Yes” each time. On a command prompt the other option, “Yes, and don’t ask again”, writes a rule for you. On an edit prompt it only lasts until the session ends. This lesson writes the rules by hand first so you can see what one looks like. Expect an edit to todo.py, an edit to test_todo.py and the test command, and don’t be surprised by a second edit to a file or a second test run. The reads before the edits never prompt, because reading a file inside the working directory doesn’t need approval [1].

When the agent reports done, read the diff, then reset with git checkout -- .. The tally is the input for the next section, so keep it.

Look down the tally and sort the lines into what the agent read, what it wrote, and what it ran. Reads are absent, since none of them prompted. Writes are the two source files. Runs are the test command, once or twice. That sorting is the file. Create .claude/settings.local.json in the fixture copy with this content. The file is JSON (JavaScript Object Notation), the same format the “don’t ask again” option writes to when it saves a rule [1].

{
"permissions": {
"allow": [
"Edit(todo.py)",
"Edit(test_todo.py)",
"Bash(python3 -m unittest *)",
"Bash(git status *)",
"Bash(git diff *)"
],
"deny": [
"Edit(todos.json)"
]
}
}

Each line is a rule with a tool name and a specifier in parentheses. For the Edit tool the specifier is a path, and a bare path such as todo.py is relative to the directory the agent runs in. For the Bash tool the specifier is the command text. A rule with no * matches one exact command, and a * matches any text, including spaces. When the only * is at the end after a space, the rule also matches the bare command, so Bash(git status *) covers git status and git status --short. The vendor’s advice is to write the command you want to run without asking and replace the parts that vary with * [1]. The git status and git diff entries are here because the plan for the run said “allow the read-only version-control commands”. Whether they’re needed is the question of the next section.

The deny entry enforces what the brief could only ask for. “Do not edit todos.json” in a prompt is an instruction, and the agent decides whether to follow it. The deny rule is enforced by Claude Code, on its own file tools and on the file commands it recognizes in a shell command, such as sed or a redirect into the file [1]. Note the limit: the fixture’s own python3 todo.py add writes todos.json from inside Python, and a rule on the Edit tool doesn’t see that. A rule matches the tool call the agent makes, and it isn’t a wall around the file.

Before the run, hold three rules of growing width against three commands. matches below is a small model of the vendor’s matching for the two kinds of rule this lesson uses, an exact rule and a rule with one trailing *. The complete file is site/examples/customizing-agents/allowlists/width.py in the repository.

import fnmatch
def matches(rule, command):
pattern = rule[len("Bash(") : -1]
if pattern.endswith(" *") and pattern.count("*") == 1:
prefix = pattern[:-2]
return command == prefix or command.startswith(prefix + " ")
return fnmatch.fnmatchcase(command, pattern)
RULES = ["Bash(git log)", "Bash(git log *)", "Bash(git *)"]
COMMANDS = ["git log", "git log --oneline main", "git push origin main"]
Checkpoint · predict

What does this print, one line per command, with the number of rules that cover it?

for command in COMMANDS:
hits = [r for r in RULES if matches(r, command)]
print(f"{command}: {len(hits)}")

Output verified in CI from site/examples/customizing-agents/allowlists/width.py.

The last line is the one to remember. The rule Bash(git *) was never written with a push in mind, and it approves one anyway. Everything in front of the * has to appear in the command exactly as you typed it, and here that is one word, git. Text after a * still has to appear too, as git log * main in the vendor’s own table shows, but this rule has none [1].

Now start the agent again in the same mode, claude --permission-mode default, and check /permissions. Every rule from the file is listed, with the file it came from. Send the same brief and tally again. The two edits and the test command go through without a prompt, and the session most likely runs to the end without stopping. If it does stop, write down what for. A prompt now means the agent ran something the list doesn’t cover, such as python3 todo.py list to try the new command. That prompt is a decision for you. Does the command belong on the list, or was this one run enough? Read the diff as carefully as in run one, then reset.

An allowlist is a standing approval. Every rule in it approves a command in every future session in this repository, watched or not. A rule with no job only widens what a future session may run without you. The test is the tally from run one: a rule stays when it answers a prompt that was in the tally. A rule that answers none of them is either for a command the agent never ran, or for a command that never prompted in the first place.

The git status and git diff entries fail that test for the second reason. Claude Code never prompts for a fixed set of commands it treats as read-only: ls, cat, grep, git status, git diff, git log and their kin. The set is the same in every mode, and you can’t add to it [1]. git status was never in the tally because it never stopped for you, and a rule for it changes nothing. Here is the prune as a program, with TALLY as the commands of a typical run one. The complete file is site/examples/customizing-agents/allowlists/prune.py, next to width.py, which it imports matches from.

ALLOW = ["Bash(python3 -m unittest *)", "Bash(git status *)", "Bash(git diff *)"]
TALLY = ["python3 -m unittest -q", "python3 -m unittest -q"]
def unused(rules, prompted):
return [rule for rule in rules if not any(matches(rule, command) for command in prompted)]
Checkpoint · predict

What does this print?

for rule in unused(ALLOW, TALLY):
print(rule)

Output verified in CI from site/examples/customizing-agents/allowlists/prune.py.

Delete those two lines from .claude/settings.local.json. The Edit rules stay, because both files were in the tally, and the deny rule stays, because a deny rule is a limit and never widens anything. The file now has four lines of rules, and each one points at a line in your tally. That is what reviewable means here: a colleague who reads the file can ask “which prompt is this for?” about every entry and get an answer.

The file grows on its own from here. The “Yes, and don’t ask again” option on a Bash prompt adds a rule for that command to this same file. The rule then applies to every later session in the repository [1]. Prune it the same way after each task: open /permissions, and for each rule ask which prompt it was for. The user-level file, ~/.claude/settings.json, holds the rules you want in every project, and the project’s .claude/settings.json holds the ones a team shares through the repository. A deny rule in any of those files overrides an allow rule in any other, because Claude Code evaluates the deny rules of every file before the allow rules of any [1].

Checkpoint · sort

Place each action from the fixture task in the list it belongs in, or in no list.

Exercise

Do the runs from this lesson, if you haven’t already, in a fresh copy of the fixture with its own first commit. Run one with nothing pre-approved and a tally of every prompt. Then write .claude/settings.local.json with the test command, the read-only git commands, the two edit rules and the deny rule, run the task again, and prune every rule that answers no line of the tally. The result is one small settings file and a tally with a line for each rule left in it. The point is to practice turning prompts into rules and rules back into a question, before you do it in a repository where the file stays in use for months.

A good result: the final file has the test command, the two Edit rules and the deny rule, the two git entries are gone, run two stopped for nothing or for one command you can name, and both diffs contain the same change. Reflection: open the settings.local.json of a repository you work in every day. Which rule in it can you no longer say the prompt for?

Stretch: Approve one prompt in run two with the don't-ask-again option instead of the plain yes, then open the settings file and compare the rule Claude Code wrote with the one you would have written.

Recap

  1. An allowlist turns a stream of prompts into a file of rules, one per command or path the task needs, and the file is what makes the decision reviewable. In Claude Code the lists are allow, ask and deny in a settings file, and /permissions shows every rule with the file it came from [1].
  2. Claude Code checks deny, then ask, then allow, and the first match decides. A narrower allow rule doesn’t win against a broader deny rule [1].
  3. A rule with no * matches one exact command. A trailing * after a space also matches the bare command, and a * right after the program name approves every subcommand, including the ones you never thought of [1].
  4. Write the rule from the tally: sort the prompts into reads, writes and runs, and give the writes and runs one line each. A read inside the working directory never prompts, so it doesn’t need a rule.
  5. After the run, delete every rule that answered no prompt. A rule for a command on the built-in read-only list does nothing, and the don’t-ask-again option adds rules to the same file, so prune it after each task.

You can now

  • Sets permissions to the least the work needs

  1. Anthropic. Configure permissions. Claude Code documentation. Reference. Claude Code permissions