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].
When a skill and the project disagree
Section titled “When a skill and the project disagree”Here is the skill. It asks for the one thing the project’s CLAUDE.md
forbids.
---name: ship-branchdescription: 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.0allowed-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.
The skill that pushes
Section titled “The skill that pushes”A team installs a Claude Code skill whose steps say to push the branch after the commit, and whose allowed-tools field lists Bash(git push *). The project's CLAUDE.md says that agents never push and that a person reviews and pushes each branch. The session runs in the default permission mode, which Claude Code labels Manual.
What does the team change?
Which of these leaves two instructions that disagree, and which control still decides when the skill's turn has pre-approved the push?
A refusal the agent can act on
Section titled “A refusal the agent can act on”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
denywith apermissionDecisionReason. 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 asBash(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 0The 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.
The hook, fed the push
Section titled “The hook, fed the push”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.
{
"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 0Output 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.
Which reason steers the agent?
Section titled “Which reason steers the agent?”A Claude Code PreToolUse hook refuses every git push in a project where a person reviews and pushes each branch. Claude sees only the text in permissionDecisionReason, and nothing else about the rule. Four versions of that text are proposed.
Which text should the hook put in permissionDecisionReason?
After the refusal, does the agent know what the rule is and which move is still open?
What does Claude get back?
Section titled “What does Claude get back?”In Claude Code, an action can be refused in several ways: by a PreToolUse hook, by a deny rule in the permission settings, or by the user at a permission prompt, where pressing Tab opens a comment field. Each item is one refusal of the same git push.
Place each refusal in the bucket it belongs to.
For each refusal, is there a place where you wrote text that Claude Code passes on?
What a bare refusal leaves open
Section titled “What a bare refusal leaves open”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.
Eleven commands against the hook
Section titled “Eleven commands against the hook”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.
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: allowOutput 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.
Which changes keep the project in charge?
Section titled “Which changes keep the project in charge?”A project's CLAUDE.md says that agents never run git push. An installed Claude Code skill says to push after each commit, and its allowed-tools field lists Bash(git push *). Four changes are proposed.
Which two changes should the team make?
Which changes remove the disagreement from the text, and which control acts before the push runs, whatever the skill says?
The push that got through
Section titled “The push that got through”A project has the Claude Code deny rule Bash(git push *) and no hook. An agent's git push origin main was refused with no reason from the project. Its next command was git -C . push origin main. The developer approved the prompt for it, expecting the rule to refuse any push, and the branch reached the remote.
Why did the second push reach the remote?
What does a Bash rule match against, and what did the agent know about the rule after the first refusal?
Which control fits the job?
Section titled “Which control fits the job?”A team uses Claude Code with an installed skill that disagrees with the project's rule that agents never push. Each row is one job the team wants done. The options are four controls: an edit to the team's copy of the skill, a PreToolUse hook, an answer at a permission prompt, and a check on the server that holds the repository.
Match each job to the control that does it.
Does the job change the text the agent reads, refuse a call with a reason before it runs, or hold 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.
cp -R site/examples/customizing-agents/project-stays-in-charge/fixture-repo ~/no-pushmkdir -p ~/no-push/.claude/hooks ~/no-push/.claude/skillscp 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.gitcd ~/no-pushmv CLAUDE.sample.md CLAUDE.mdgit init -q && git add -A && git commit -q -m "fixture"git remote add origin ~/no-push-remote.gitLeave 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.
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
- 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. - A skill’s
allowed-toolscan 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]. - 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]. - 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.
- 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
References
Section titled “References”- Anthropic. Hooks reference. Claude Code documentation. Reference.
Claude Code hooks - Anthropic. Configure permissions. Claude Code documentation. Reference.
Claude Code permissions - Anthropic. How Claude remembers your project. Claude Code documentation. Reference.
Claude Code memory - Anthropic. Extend Claude with skills. Claude Code documentation. Reference.
Claude Code skills