# Keeping the project in charge

> When an installed skill asks for a push the project forbids, remove the conflict from your copy of the skill, refuse the push with a PreToolUse hook whose reason names the open route, and see why a bare refusal leaves other spellings of the same command open.

Page: https://schubergphilis.github.io/ai-training/customizing-agents/project-stays-in-charge/
License: CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/

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 (Hooks reference, Claude Code documentation) (Configure permissions, Claude Code documentation).

## When a skill and the project disagree

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

```markdown
---
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 (How Claude remembers your project, Claude Code documentation). 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 (Extend Claude with skills, Claude Code documentation). Deny and ask rules still
override that grant (Extend Claude with skills, Claude Code documentation). 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 (Configure permissions, Claude Code documentation). A
deny decision in the hook's JSON output prevents the call too
(Hooks reference, Claude Code documentation). 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](https://schubergphilis.github.io/ai-training/customizing-agents/adopting-a-skill-set/#take-two-skills-at-a-time)
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: The skill that pushes

What does the team change?

- Add a line to CLAUDE.md saying that the project rules override every installed skill.
- Keep both files as they are, and rely on the default mode to ask before every git push.
- Remove the push step and its grant from your copy of the skill, and add a hook that refuses a push.
- Delete the push rule from CLAUDE.md, because the skill is the newer of the two instructions.

## 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 `deny` with a
  `permissionDecisionReason`. Claude Code prevents the call and shows
  Claude the reason (Hooks reference, Claude Code documentation).
- A hook that exits with 2 blocks the same way, and its text on standard
  error becomes the reason (Hooks reference, Claude Code documentation).
- 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 (Configure permissions, Claude Code documentation).
- 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 (Configure permissions, Claude Code documentation).

[Writing a hook that blocks a mistake](https://schubergphilis.github.io/ai-training/customizing-agents/first-hook/)
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 (Hooks reference, Claude Code documentation). Here is the core of it. The
complete file is
`site/examples/customizing-agents/project-stays-in-charge/no_push_guard.py`.

```python
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: 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.

Output:

```text
{
  "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
```

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.

```json
{
  "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 (Hooks reference, Claude Code documentation). 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: Which reason steers the agent?

Which text should the hook put in `permissionDecisionReason`?

- Agents don't push in this project. Commit on the branch and tell the user it is ready.
- git push isn't allowed for agents in this repository. Use another command to publish.
- The remote isn't ready for this branch yet. Wait a minute, then run the same push again.
- Blocked by no_push_guard.py, the PreToolUse hook that is set in the local settings.

#### Checkpoint: What does Claude get back?

Place each refusal in the bucket it belongs to.

Buckets:

- Claude gets a reason you wrote
- Claude gets no reason you wrote

Items:

- A hook prints a deny decision with a permissionDecisionReason
- A deny rule Bash(git push *) in the project settings matches
- You answer No at the prompt and type a comment after Tab
- A hook exits with code 2 and writes a message to standard error
- You answer No at the prompt in the main conversation, without a comment

## 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 (Configure permissions, Claude Code documentation).

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: 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.

Output:

```text
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
```

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 (Configure permissions, Claude Code documentation). For a
push in particular, the remote can refuse it, whatever ran the command.

#### Pitfall: The deny rule that said nothing

A team saw that an installed skill pushed branches, and added the deny
rule `Bash(git push *)` to the project settings. They left the skill as
it was. In the next session the skill asked for the push, the rule
refused it, and the agent tried `git -C . push`, which the rule doesn't
match. The developer approved the prompt for it, sure that the rule
refused every push, and the branch reached the remote before review. The
rule did its one job, and the skill still asked for the push in every
session. Remove the conflict from your copy of the skill, and refuse
with a reason that names the open route, so the agent has a better move
than a second spelling.

## 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.

```sh
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.

```sh
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 (Hooks reference, Claude Code documentation). It applies the skill's
`allowed-tools` either way (Extend Claude with skills, Claude Code documentation). 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
   (How Claude remembers your project, Claude Code documentation). 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 (Extend Claude with skills, Claude Code documentation). A
   PreToolUse hook that exits with 2 blocks the call even when an allow
   rule matches (Configure permissions, Claude Code documentation).
3. A PreToolUse hook can deny a call with a JSON decision, and Claude
   sees its `permissionDecisionReason` (Hooks reference, Claude Code documentation). A No with a
   comment at a prompt sends the comment the same way
   (Configure permissions, Claude Code documentation).
4. A Bash rule matches command text and misses other spellings of the
   same command (Configure permissions, Claude Code documentation). 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` (Configure permissions, Claude Code documentation),
   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.

#### Habit

The next time you deny an agent's action, at a prompt or in a hook, say
in one sentence what the rule is and what the agent should do instead.

## References

- Hooks reference, Claude Code documentation, https://code.claude.com/docs/en/hooks
- Configure permissions, Claude Code documentation, https://code.claude.com/docs/en/permissions
- How Claude remembers your project, Claude Code documentation, https://code.claude.com/docs/en/memory
- Extend Claude with skills, Claude Code documentation, https://code.claude.com/docs/en/skills
