Skip to content

Keeping the project in charge

A colleague installs a skill called ship-branch in your project. It commits the finished work and then pushes the branch. Your project has a rule that agents never push: a person reviews each branch and pushes it. In this lesson we keep that rule in charge. We take the conflict out of our copy of the skill and add a hook that refuses the push with a reason. Then we check that the agent takes another route after the refusal.

The skill is invented for the course, and so is the small project it runs in. Both are in the course repository at site/examples/customizing-agents/project-stays-in-charge/, next to the hook. The events, the JSON fields and the rule syntax are Claude Code’s, checked against the vendor’s documentation on the date at the end of the lesson [1] [2].

Here is the skill. It asks for the one thing the project’s CLAUDE.md forbids.

---
name: ship-branch
description: Commit the finished work and publish the branch. Use when the user says the work is done or asks to ship it.
license: Apache-2.0
allowed-tools: Bash(git add *) Bash(git commit *) Bash(git push *)
---
# Ship the branch
1. Run `git status` and check that only the files of this task changed.
2. Stage those files and commit them with a short message in the imperative.
3. Push the branch with `git push -u origin HEAD`, so the work is never only
on this machine.
4. Report the branch name and the commit.

Step 3 works against the project. It is an instruction, and the project’s CLAUDE.md gives the opposite one. Given two instructions that disagree, Claude may act on either of them [3]. A third line that says “follow the project, not the skill” only adds one more instruction to weigh. The fix is to remove the conflict, and the place to remove it is your copy of the skill. The project’s rule stays as it is.

The allowed-tools line also works against the project. The tools it lists are pre-approved for the turn in which the skill starts, so Claude runs them with no prompt. The skill’s author has pre-approved git push in your project [4]. Deny and ask rules still override that grant [4]. Exit code 2 from a PreToolUse hook ends the call before Claude Code looks at any permission rule, and a matching allow rule then has no effect [2]. A deny decision in the hook’s JSON output prevents the call too [1]. So the project’s hooks and permission rules decide, and the skill’s front matter doesn’t widen them.

For ship-branch, delete step 3 and Bash(git push *) from your copy, and write down the version you copied, as Adopting a published skill set did for two skills. The next update of the skill may bring the step back, and the agent may still try a push on its own. The project needs a control that holds whatever the skill says, and the rest of this lesson builds one.

Checkpoint · scenario

What does the team change?

A refusal does more than stop one action. When a hook or a person blocks an action and says why, the reason goes back to the model, and the agent plans its next step with it. We call this steering by rejection. The reason is the only part of the hook the agent sees at that moment, so it should say what the rule is and which way is open. Here is what Claude gets back from each kind of refusal in Claude Code.

  • A PreToolUse hook can print a JSON decision of deny with a permissionDecisionReason. Claude Code prevents the call and shows Claude the reason [1].
  • A hook that exits with 2 blocks the same way, and its text on standard error becomes the reason [1].
  • When you answer No to a permission prompt, you can press Tab and type a comment. The comment reaches Claude as the reason for your refusal, and the session continues. Without a comment, a No in the main conversation ends Claude’s turn [2].
  • A deny rule in permissions.deny, such as Bash(git push *), blocks every call that matches. A rule is a tool name and a pattern, so it has no place for a reason of yours [2].

Writing a hook that blocks a mistake used the exit code and standard error. This hook uses the JSON form. It exits with 0 and prints a hookSpecificOutput object with the event name, the decision and the reason, the fields the hooks reference gives for PreToolUse [1]. Here is the core of it. The complete file is site/examples/customizing-agents/project-stays-in-charge/no_push_guard.py.

REASON = (
"This project doesn't let agents run git push. A person reviews each branch "
"and pushes it. Leave your commits on the current branch, don't look for "
"another way to push, and tell the user which branch is ready."
)
def main() -> int:
try:
call = json.load(sys.stdin)
command = call["tool_input"]["command"]
if not isinstance(command, str):
raise TypeError("command must be a string")
except (ValueError, KeyError, TypeError):
print(
"Blocked: the no-push hook couldn't read the tool call it was given.\n"
"Check the hook's entry in .claude/settings.local.json.",
file=sys.stderr,
)
return 2
try:
denied = pushes(command)
except Exception as error:
# A crash has to block the call, and exit code 1 doesn't block.
print(f"Blocked: the no-push hook failed on this command: {error!r}", file=sys.stderr)
return 2
if not denied:
return 0
decision = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": REASON,
}
}
print(json.dumps(decision, indent=2))
return 0

The reason names the rule, says who does the push instead, and gives the agent its next step. It also asks the agent not to look for another way, for a reason the next section shows. pushes splits the command into words the way a shell does, which removes the quotes, and into subcommands at shell operators such as ;, &&, | and &. It joins a line that ends in an unescaped backslash to the next one, as the shell does. In each subcommand it skips leading NAME=value assignments and words such as env and nohup that start the next word as a program, with their options. It checks that the program is git by any path, skips git’s own options such as -C ., and returns true when git’s command is push. The shell expands a word such as {push,} or $CMD before it runs the command. When the program or git’s command is such a word, the hook can’t know the result, so it returns true if the rest of the subcommand contains push. When the input isn’t a tool call it can read, the hook exits with 2 and writes why to standard error. A broken hook then blocks the call.

The fixture deny.py pipes the hook the call that the skill’s step 3 would make, the same way the exercise does by hand.

Example · run it

From the root of the course repository, run python3 site/examples/customizing-agents/project-stays-in-charge/deny.py and compare what you see with the output below.

Output
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "This project doesn't let agents run git push. A person reviews each branch and pushes it. Leave your commits on the current branch, don't look for another way to push, and tell the user which branch is ready."
  }
}
exit code 0

Output verified in CI from site/examples/customizing-agents/project-stays-in-charge/deny.py.

The exit code is 0 and the call is still refused, because the decision is in the JSON. Register the hook in .claude/settings.local.json of your copy, with the Bash matcher and the exec form from the last lesson.

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/no_push_guard.py"]
}
]
}
]
}
}

This entry has no if field, and without one the hook runs for every Bash command that the matcher picks [1]. The script reads only the text of the command and doesn’t start another program, so it returns right after it has read the command.

Checkpoint · choice

Which text should the hook put in permissionDecisionReason?

Checkpoint · sort

Place each refusal in the bucket it belongs to.

A refusal without a reason still blocks the one call. What it doesn’t do is tell the agent what to do next, and the next step may be the same action in another form. A Bash rule compares a pattern with the command as Claude wrote it. The permissions page lists forms that a deny rule for Bash(git push *) doesn’t stop, such as git -C . push origin main, git -c push.default=current push origin main and git 'push' origin main. By the page’s own account, such a rule covers the way Claude usually writes the command, and it doesn’t guard the program itself [2].

The vendor pages don’t say what Claude does after a refusal that gives no reason. What follows is our inference from the documented limits of Bash rules. An agent that is still trying to finish the task, and that doesn’t know the rule, may try a second spelling of the same command. If that spelling is one the rule doesn’t match, the rule doesn’t stop it, and only the permission prompt stands between that push and the remote. A reason that names the open route gives it a better move than the second spelling. The exercise lets you watch both cases.

A hook is code, so it can catch more spellings than a pattern can. The fixture spellings.py holds eleven commands against the same hook: nine spellings of a push, a push through sh -c, and a git log that only mentions the word. The wrapped command takes two lines of the output.

Example · run it

From the root of the course repository, run python3 site/examples/customizing-agents/project-stays-in-charge/spellings.py and compare what you see with the output below.

Output
git push origin main: deny
git -C . push origin main: deny
git -c push.default=current push origin main: deny
git 'push' origin main: deny
/usr/bin/git push origin main: deny
git -C "" push origin main: deny
git -C . \
push origin main: deny
env git push origin main: deny
git {push,} origin main: deny
sh -c 'git push origin main': allow
git log --oneline --grep push: allow

Output verified in CI from site/examples/customizing-agents/project-stays-in-charge/spellings.py.

The three forms from the vendor’s table are refused, and so are git by its full path, an empty -C directory (which git reads as the current one), a line wrapped with a backslash, env in front of git, and a word that the shell expands to push. The push through sh -c gets through: the hook sees sh, and the push is inside a quoted string that it doesn’t read. The hook also misses a push inside backticks, as in echo `git push` , or inside $(...). It misses a push word held in a variable, as in X=push; git $X, and a script such as ./publish.sh that pushes. So the hook makes the refusal and its reason reach the agent for the spellings it reads, and we expect the reason to make a search for another spelling less likely. Neither the rule nor the hook is a boundary. For a limit that doesn’t depend on the command text, the permissions page points to the sandbox [2]. For a push in particular, the remote can refuse it, whatever ran the command.

Exercise

Make the copy from the root of your clone of the course repository. It holds the project, the ship-branch skill, the hook, and a remote that’s a local folder, so a push that gets through goes nowhere else. If ~/no-push or ~/no-push-remote.git exists from an earlier try, remove it first.

Terminal window
cp -R site/examples/customizing-agents/project-stays-in-charge/fixture-repo ~/no-push
mkdir -p ~/no-push/.claude/hooks ~/no-push/.claude/skills
cp site/examples/customizing-agents/project-stays-in-charge/no_push_guard.py ~/no-push/.claude/hooks/
cp -R site/examples/customizing-agents/project-stays-in-charge/ship-branch ~/no-push/.claude/skills/
git init -q --bare ~/no-push-remote.git
cd ~/no-push
mv CLAUDE.sample.md CLAUDE.md
git init -q && git add -A && git commit -q -m "fixture"
git remote add origin ~/no-push-remote.git

Leave the skill as it is for now, so the conflict is there. Test the hook by hand. It prints the same JSON and exit code 0 as deny.py above. Then create .claude/settings.local.json with the entry above.

Terminal window
echo '{"tool_input": {"command": "git push -u origin HEAD"}}' | python3 .claude/hooks/no_push_guard.py; echo "exit code $?"

Start claude in ~/no-push, accept the workspace trust dialog, and check /hooks. Claude Code holds back hooks from settings files until you accept it [1]. It applies the skill’s allowed-tools either way [4]. Then send: “Add Water the plants to todo.txt, then use the ship-branch skill.” The skill can only change files and run git in this copy. In a second terminal, run git -C ~/no-push-remote.git rev-list --all --count.

A good result: the commit is in git log of the copy, the remote check prints 0, and the agent’s reply says the branch is ready for a person to push. Then make the fix from the first section in your copy of the skill, and send the prompt again in a new session. Note whether a push is attempted this time. Fifteen minutes is enough. Reflection: which skill or prompt in your own projects asks for something your project forbids, and what would the reason in its refusal say?

Stretch: Remove both folders and make a fresh copy with the same steps. In it, run git rm -q CLAUDE.md and commit, so the agent learns nothing about the rule, and change REASON in the hook to the single word Blocked. Start the session with CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 claude, so that no note from the first run tells the agent the rule, and send the same prompt. Write down every command the agent tries after the refusal, and run the remote check again.

Recap

  1. When two instructions disagree, Claude may follow either one [3]. For a skill’s step that goes against the project’s CLAUDE.md, remove the conflict in your copy of the skill, and leave the project’s rule as it is.
  2. A skill’s allowed-tools can pre-approve a command for its turn, and deny and ask rules still override it [4]. A PreToolUse hook that exits with 2 blocks the call even when an allow rule matches [2].
  3. A PreToolUse hook can deny a call with a JSON decision, and Claude sees its permissionDecisionReason [1]. A No with a comment at a prompt sends the comment the same way [2].
  4. A Bash rule matches command text and misses other spellings of the same command [2]. That a bare refusal invites such a spelling is our inference, so give a reason that names the open route.
  5. A Bash rule misses a push inside sh -c [2], and so does this lesson’s hook, because it doesn’t read inside the quoted string. For a limit that holds whatever ran the command, use the sandbox or a check outside the agent.

You can now

  • Keeps the project's own instructions, hooks and permissions in charge when a skill set disagrees

  1. Anthropic. Hooks reference. Claude Code documentation. Reference. Claude Code hooks
  2. Anthropic. Configure permissions. Claude Code documentation. Reference. Claude Code permissions
  3. Anthropic. How Claude remembers your project. Claude Code documentation. Reference. Claude Code memory
  4. Anthropic. Extend Claude with skills. Claude Code documentation. Reference. Claude Code skills