A guardrail set for a team of six
Three hooks are a habit; six people is a system. What travels in the repo, what can't, and one script that tells you which machine is quietly missing a gate.
Three hooks, and then five other people
We've already had three guards running on our machine — pre-tool gate which doesn't let in commits with changed .env, the repo's checks disabled, or force-push without lease, a formatter that runs in the write mode and a stop check that blocks the turn from finishing if there are tests failing; we've seen all of them being called on our machine.
Then we invited 5 more people. And we faced 3 problems:
The registration hasn't worked as it should. Five out of six harnesses were keeping the hook registration in a committable file. The sixth one was just pointing to a file in user's home directory, which wasn't committable at all. One of these five harnesses was ignoring new config until devs accept it manually
We still have this fail-open problem we've mentioned in the first lesson. It's much more problematic on a scale of a team, because if some guard isn't loaded at all, it's the same as it allowing everything. Ofc if it were our machine, we'd be eventually noticing it, but that's not true for six machines. Same for the fact that not adding a hook is not an error, so no one gets notified
The most important thing — what we actually commit is a command that will run on other people's machines. So we need to define what belongs in the repo, and what doesn't; what the set looks like
What travels in the repo, and what can't
The guards are split between what the repo is about, and what is per machine. We commit scripts, and let each harness handle registration. That's why both chapter guards are scripts under scripts/ directory (not files inside any harness config directory), so that six harnesses can point to one script and it gets code review like every other piece of code.
There are multiple registration locations for different harnesses, some of them committable, some being files in user's home directories:
| harness | committed with the repo | personal, not committed |
|---|---|---|
| Claude Code | .claude/settings.json | .claude/settings.local.json |
| Codex CLI | <repo>/.codex/hooks.json — active only after each dev accepts it | ~/.codex/hooks.json |
| GitHub Copilot CLI | .github/hooks/*.json | ~/.copilot/settings.json |
| Cursor | <project>/.cursor/hooks.json | ~/.cursor/hooks.json |
| Antigravity CLI | .agents/hooks.json | none documented |
| Kimi Code CLI | none documented | ~/.kimi-code/config.toml |
We'd say the scripts should be robust and the registration part minimalistic — a single line with a path, as standardised across all six harnesses as it's possible (closer than you might expect, but not ideal in any case); everything more complex in the registration department is sth that gets re-entered on every other machine (on Kimi Code it's entered manually too, actually).
There's also a little edge case. Kimi Code's docs just point to a TOML file in user's home directory, with no project-level equivalent. So if we were to aim for a team-wide setup of this set, it'd mean asking people to add lines in their configs; we can't enforce that from the repo level, so the best we can do is to create a script that reads their configs and shows what's missing.
A committed hook is a command that runs on their machine
The other half of the picture is that we commit a hook, which means it runs on another dev's machine. Let's see this:
We initialised a directory and opened Claude Code 2.1.221 in it for the first time,
We created a project-level settings file and registered a
SessionStarthook there, pointing to a script that writes to a file outside the repoThen we started a turn and...
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command",
"command": "echo \"hook ran as $(id -un) in $(pwd)\" >> /tmp/fresh-clone-marker.txt" } ] }
]
}
}$ claude -p "Reply with the single word: ok"
ok
$ cat /tmp/fresh-clone-marker.txt
hook ran as lizzy in /tmp/fresh-clone-ocbQThat was one run. The hook got called on the very first turn, ran as a regular user of our machine in the directory we opened for the first time, with no prompt asking us if we trust it; that's exactly what we want for our guard, and simultaneously — it's git pull running a command on a teammate's laptop.
Except for Codex, which calculates trust based on the hook definition hash. So if you modify or add a hook, it gets ignored until you review it in /hooks, which they express most clearly in the help for the binary's flag that disables requiring trust; they say it's dangerous and should be used only if you automate something that validates where the hook definitions come from.
$ codex --help | grep -A3 dangerously-bypass-hook-trust
--dangerously-bypass-hook-trust
Run enabled hooks without requiring persisted hook trust for this invocation.
DANGEROUS. Intended only for automation that already vets hook sourcesBut everywhere else it's up to you. A change in a hooks file is treated the same way as a change in a CI workflow — they both alter sth that's a command the team runs automatically, and this one file is the place where "looks good" and "I read it" differ.
To make it easy to do a proper code review we keep our guards dependency-free and short, so they can be read in one sitting; these are just a Python file importing standard library and a shell script depending on checks that the repo already runs. We don't aim for minimalism here, we aim for 5-minute reviews instead of supply-chain nightmares.
Our guards also don't fetch anything — no curl, no installation steps, no remote policy endpoint on the way of every tool call; the org-wide policy belongs to an admin layer we'll be talking about later, not sth you can push.
The set, and why it stays small
Now, let's have a look what actually gets commited:
The guard for pre-tool event that prevents commits landing on main, with changed .env, repo checks disabled or force-push without lease.
A check script in the format mode for after-write event — it formats the file that has just been written, with no output
Another check script, this time in the verify mode for stop event — it lints the changes and runs tests before the turn is finished
There are also slight differences between harnesses — some of them register all three hooks, others only the gate and the stop check, which are the two that alter what a session can do. The formatter is the same one-liner registration pointing to an after-write hook, wherever you want it. But think about it — every before-tool or after-write hook is on the path of every tool call during every session, multiplied by six people and every day.
Let's focus on the guard for pre-tool event for now, it's the most sensible thing to have in the set. We've run a benchmark of 20 runs for it — 27ms if the command has no git in it (and so it returns early), 65ms if it is one. That's good for a single gate; and it's the argument against a fourth.
There are also a few things that aren't included:
No forbidden commands list — git commit has too many flavours, deny lists lose by default; the proper mechanisms to implement this are permissions and sandboxes, and if you're in a gate-everything team, there's a lesson on permission fatigue.
No read-side gate for files an agent shouldn't open — that's about permissions, not hooks, so there's a separate lesson for it
But seriously, we can't add the fourth guard until your team has actually had to revert a thing like this. Not just "would be nice to" or "we could".
The gate you can't see is the gate you don't have
Hooks fail open almost everywhere, so a guard you don't actually have looks exactly like one that lets everything through. With six machines that's six opportunities for it to silently not work, by design — if you don't have a guard it won't say anything. That's why we created a script that's part of the set, which can be treated as its inventory and as a test that it still works. It has a table with guards in it, and every guard reads a hook's payload from stdin and responds with an exit code, so it boils down to payloads and exit codes — no harnesses, no LLM-s, no tokens.
GUARDS = (
("commit-guard", ("python3", "scripts/commit-guard.py"), (
("lets an ordinary command past", pre_tool("npm test"), PROCEED),
("refuses --no-verify", pre_tool("git commit --no-verify -m wip"), REFUSE),
# ...three more cases
)),
("check.sh", ("scripts/hooks/check.sh", "verify"), (
("stands down mid-loop", stop(mid_loop=True), PROCEED),
)),
)$ python3 scripts/guardrails.py check
ok commit-guard: lets an ordinary command past
ok commit-guard: refuses --no-verify
ok commit-guard: refuses the clustered -n
ok commit-guard: refuses a bare force push
ok commit-guard: allows --force-with-lease
ok check.sh: stands down mid-loop
ok — 6 casesA suite that can't go red isn't worth running, so break one on purpose before you believe it. We changed the guard to stop handling the -n (clustered short flag) case, which is the careless edit an actual person makes, since -n looks like a detail. So now it says:
ok commit-guard: refuses --no-verify
FAIL commit-guard: refuses the clustered -n — wanted exit 2, got 0
...
1 failing — 6 casesEvery case has to hold in any checkout, on any branch, otherwise it fails in CI for reasons that have nothing to do with the guards. That rules out the two we'd most want — a regular commit is refused on main but allowed on a topic branch, and the .env rule only trips in a tree that has an .env file. Both are totally genuine behaviours, but we can't have them in a shared suite, so the fixtures cover the logic that works everywhere.
The branch-dependent half is what you check by hand once, the way the earlier lesson does.
Which machine is missing a gate
The other half of the picture is a report mode — a script that looks where each harness keeps its config (the two locations per harness, plus possible alternatives somebody might have used) and reports for each harness what it's missing. On our machine for example:
$ python3 scripts/guardrails.py report
Claude Code check.sh, commit-guard (.claude/settings.json)
Codex CLI commit-guard (.codex/hooks.json)
missing: check.sh
Copilot CLI nothing registered — the whole set is missing here
Cursor commit-guard (.cursor/hooks.json)
missing: check.sh
Antigravity CLI nothing registered — the whole set is missing here
Kimi Code CLI nothing registered — the whole set is missing hereThis is the natural state of things when you have six people involved, no one to blame here — we had all guards in our set, two people who copied our pre-tool gate didn't add stop hook for it and harnesses that nobody uses have nothing in them.
It's also safe to run on a colleague's machine, as it only reads the configs. But keep in mind that it locates your guard based on its path occurring in the config text (that's intentional — we could make it six times more complex by parsing six different config languages to find where a command is defined, but that's not worth it). So it can't tell if you've attached your guard to a proper event, and it doesn't know enterprise layers of these tools exist.
But the most important thing is that even when the check mode passes (which means the guard's logic is correct) and the report mode is green (which means it's registered on this machine), you still only get proof from one real session where you ask for the thing and watch it get refused. You can't omit this, ever. That's what we say all through this chapter, and a team is exactly where it gets skipped — somebody checked it, on another machine, a few weeks ago.
Where the actual enforcement lives
What we're saying is that if you create such a set, you don't enforce anything. It's a convention, not a control. Any of six people can simply choose not to have it, four of these harnesses provide an explicit way to opt-out and for example on Claude Code there's a switch in the help for the CLI to turn hooks off together with other customisations.
$ claude --help | grep -A3 -E '--bare|--safe-mode'
--bare Minimal mode: skip hooks, LSP, plugin
sync, attribution, auto-memory,
background prefetches, keychain reads,
and CLAUDE.md auto-discovery. Sets
--
--safe-mode Start with all customizations
(CLAUDE.md, skills, plugins, hooks, MCP
servers, custom commands and agents,
output styles, workflows, custom themes,So you need to accept that, because it guards against mistakes and the entire team wants it. If somebody is really determined to bypass it, they won't have any problems with it, so you can't make the guard more robust; you can only move the check to a place where this particular person can't reach — either on a server or in CI, next to the tests, so the guards keep working even as somebody edits them.
But there's an actual admin layer in many of these tools, which is worth being aware of so you can reject it:
Claude Code — managed settings that
disableAllHookscan't switch off from belowCodex CLI — managed hooks that can't be turned off from the user's hook browser
Copilot CLI — policy hooks in
/etc/github-copilot/policy.d/*.json, root-owned files loaded before anything else, withdisableAllHooksbeing ineffectiveCursor — Team and Enterprise layers above the project file
The important thing is that most of these are usually deployed by whoever manages machines (which is nobody if you're a team of six people), and a control your team can't inspect or modify isn't a good trade at this scale.
The escape hatch you document on purpose
The last point is the most crucial one — whether the set will survive the quarter, because what can happen is that somebody finds themselves blocked by a guard on a Friday evening and decides to remove it from their machine. And they also remove the useful rules that come with it.
So document it — put a line in the repo's onboarding notes: where this set is, how to turn it off for a single session and where to report if a rule behaves badly. But we need to be honest here, because on Claude Code for example you can only disableAllHooks which is an all-or-nothing thing, and the docs say that hooks can't be disabled individually while remaining in the configuration. So what you actually do is turning off the set for the session and reporting a rule.
And keep in mind that if a rule behaves badly, it's a bug just like a failing test, because otherwise people will get used to bypassing the entire set. The earlier lesson in this chapter watched an over-broad draft of the guard refuse git commit --help. That's how it goes — one false refusal costs you a week of your team's goodwill, and the second one costs you the file.
In Claude Code
In theory there are two config files — .claude/settings.json and .claude/settings.local.json. The first one is for settings that are tracked in the source code and should be shared between team members, while the second one is for personal preference and for tinkering. So it makes sense to put the team's hook set only into the former file so it becomes a part of every project clone.
This is what the committed hook config could look like: PreToolUse matched on Bash running the commit guard, PostToolUse matched on Edit|Write formatting whatever was just written, and Stop running the verify check.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/commit-guard.py\"" } ] }
],
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/hooks/check.sh\" format" } ] }
],
"Stop": [
{ "hooks": [ { "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/hooks/check.sh\" verify" } ] }
]
}
}As long as you use $CLAUDE_PROJECT_DIR it doesn't matter in which subdirectory of the project you start the session. It's just a path to the project's root dir and it will point there no matter what. But this is especially useful on teams, because every member works on the project in a different directory, and hence has it cloned to a different path (which may even include a space, so you need to quote the variable — the docs say so plainly).
What's important, in order for these two layers — shared across the team and individual — to work together, there are two mechanisms in place:
The hook definitions are merged rather than overwritten. So if a teammate had their own user-level hooks defined in
~/.claude/settings.jsonbefore you created the project and committed the settings with this hook, they will keep working alongside the repo's set during the session.Duplicate handlers are removed automatically (Claude Code compares the command string and its
args), so the person who already had the commit guard in~/.claude/settings.jsonbefore you committed it won't run it twice.
In terms of opting out — "disableAllHooks": true is a valid option for any of the settings files; but there's no way to set it up so that only some hooks are disabled, you can either enable them all or none. Well, except for one thing: if you set it in your user's, project's or local settings, it won't turn off hooks that were set up by an admin via a managed policy.
Also, make sure to remember, that both --bare and --safe-mode flags launch the session with hooks disabled, so there's no pre-Bash gate for you to run there nor the stop-time check.
The /hooks command opens a read-only browser of the configured hooks. It's especially useful on teams, because the most valuable information is the path to the settings file from which a particular hook originates, so you could know which ones come from the project's .claude/settings.json (that are shared) and which are local additions from a teammate.
Last but not least, remember that there's no confirmation prompt for hooks in the project's .claude/settings.json — if you clone the project to a directory that hasn't ever been used with Claude, and then run it, the hook runs on the first headless turn without asking. The trust dialog is mentioned in the docs, but it's about hooks defined in a subagent's frontmatter, not these; so keep your eyes peeled whenever you see changes in this file.
In Codex CLI
The hooks feature is stable in Codex CLI 0.146.0 — run codex features list and you'll see it. The configuration file is located at <your_repo>/.codex/hooks.json. Codex uses the same event names and payload shape as Claude Code, so if you already have hooks in your Claude Code project you can easily integrate them, the registration is close to copy-paste:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/scripts/commit-guard.py\"",
"timeout": 30 } ] }
],
"Stop": [
{ "hooks": [ { "type": "command",
"command": "\"$(git rev-parse --show-toplevel)/scripts/hooks/check.sh\" verify",
"timeout": 120 } ] }
]
}
}The configuration file above for instance defines a pre-use-hook in Python (30-second timeout) and a stop-hook in Bash (2-minute timeout). As said, these are "layers", not replacements — anything matching is run on every level from user to project to system. So, if you define a hook in this file it will be run even if you have another hook that's listening for the same event on any of the other levels. That's why it's best to resolve paths in your scripts relative to the repository root rather than from .codex/ as then you can run Codex from within subfolders.
We'd say the most important thing to keep in mind is that changes to the configuration file are ignored until you accept them on your side. Non-managed (command) hooks have to be reviewed and trusted before they can run. The trust is bound to the hash of the hook definition, so if you modify one or create a new one it gets marked as "needs review" and skipped until you accept the change. The /hooks screen is per-machine, so that review happens once per person rather than once per team, and in case of project-local hooks you'll also need to trust the .codex/ layer in order to run them — if you don't, only user and system hooks will be loaded. If there are changes pending, Codex will show a warning on startup, and you can go to /hooks to inspect sources, review the changes, accept them, or disable individual hooks if needed.
That's especially important to keep in mind because if you tighten a guard in the repo and a teammate pulls it, they aren't actually running it until they visit the /hooks screen — so "I pushed the fix" and "the fix is live" are two different statements. And guardrails.py report on their machine won't show a gap either, because the hooks file is right there in the checkout and the report only reads files. So it's a bit of extra friction, but it's what you get in exchange: this is the one harness here where a hook you pulled from somewhere else can't run without you realising it.
Also, if you ever want to run an already enabled hook without the trust, you can use --dangerously-bypass-hook-trust but this is only for cases like CI where you know what you're doing and your automation uses already reviewed hooks, if you want to skip the review on your laptop this is a way to do it, but it also gets rid of the one layer of protection we can have in such scenario.
In GitHub Copilot CLI
Setting up GitHub Copilot CLI in the repo works via JSON files placed under .github/hooks/, this way they're being reviewed by the same people who review the GitHub Actions workflows sitting next to them. The format of a hook file is determined by the event name: spell it PascalCase and you get Claude's matcher semantics together with the snake_case, VS Code-compatible payload — which is exactly the pair our shared guard script already consumes, so we get a single way of registering hooks that works for a team on mixed tools. For example:
{
"version": 1,
"hooks": {
"PreToolUse": [
{ "type": "command", "matcher": "Bash",
"bash": "python3 scripts/commit-guard.py", "timeoutSec": 30 }
],
"agentStop": [
{ "type": "command",
"bash": "./scripts/hooks/check.sh verify --report=block", "timeoutSec": 120 }
]
}
}In terms of enforcing the policy, this is the most robust way if you ever want to enforce sth:
Policy hooks are being installed globally on the system level by an administrator (POSIX:
/etc/github-copilot/policy.d/*.json, Windows:C:\ProgramData\GitHub\Copilot\policy.d\*.json);They're being run before any other hook, they work regardless of the folder trust state, and they can't be switched off with
disableAllHooks;POSIX systems require those files to be owned by root and not have group or world write permissions set;
They require elevated access to install, so they're not alterable by regular users
That's actual enforcement rather than a gentlemen's agreement, but an enterprise-level one; not sth we'd recommend for a team of six. So it'd be good to make a conscious decision about the tier at the very beginning rather than learning the difference afterwards.
Also important: the two events in this set reject things in different ways, which is a common mistake people make when porting hooks between harnesses
The pre-tool event rejects by returning exit code 2 — it's fail-closed (
permissionRequestbehaves the same way on exit 2, it's just not in our set);The stop event rejects by writing a block decision with a reason to stdout (which is what the
--report=blockflag does), so it uses this JSON:{"decision": "block", "reason": "…"}
timeoutSec is set to 30 by default, which isn't enough for an actual test suite. And every timeout falls open, the pre-tool event included
For example, this chapter found that on version 1.0.77 repository hooks aren't run at all in a freshly created directory with the -p flag. That's probably because of folder trust — the docs say policy hooks work regardless of the folder trust state, which implies the others don't. This means a new joiner might open their first session without any guard, and we should be extra vigilant with CI runners. We also can't just go and ask, because in contrast to Claude Code and Codex, 1.0.77's interactive command list doesn't provide a browser for hooks. So the only way to verify is watching the set actually refuse sth.
In Cursor
The Cursor config is located at <project>/.cursor/hooks.json and tracked by git. Cursor splits its events per tool, so the gate binds to shell commands only and leaves every other tool call alone. It looks like:
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{ "command": "python3 ./scripts/commit-guard.py", "timeout": 15, "failClosed": true }
],
"afterFileEdit": [
{ "command": "./scripts/hooks/check.sh format", "timeout": 60 }
],
"stop": [
{ "command": "./scripts/hooks/check.sh verify --report=followup",
"timeout": 120, "loop_limit": 1 }
]
}
}So that's three registrations: the guard on beforeShellExecution, the formatter on afterFileEdit, and the verification on stop — each with its own timeout.
The priority is as follows:
Enterprise
Team
Project
User
The most similar thing we have to the "share and forget" experience here is the Team level. It's synced from the dashboard so it's actually a cloud-based solution which means that if it's turned on for your team, it can be used to provide all the six of you with this set without anyone editing files locally. Whether you have it is a question for whoever owns your Cursor account. If you do, the best practice is to put the common set there and define only project-specific stuff in the .cursor/hooks.json file in the repository.
The failClosed parameter defaults to false, so it's good to be conscious about setting it per hook instead of taking the general value. For example:
for the guard — on — so a broken guard refuses the commit instead of waving it through
for formatting and verification — off — a teammate might not have the formatter installed, and they should still be able to work
Set it to true on those two and the whole set gets deleted from someone's machine pretty fast, the first time it fails on a dependency they don't have locally.
The biggest pitfall when it comes to moving a configuration to a repository is that paths are relative, so for <project>/.cursor/hooks.json they're relative to the project's root while for ~/.cursor/ they're relative to ~/.cursor/. It means that if you want to use any hook from someone's personal config, you need to adjust its path; otherwise, it will silently do nothing when run. Also, the cloud agents don't emit many events like sessionStart, sessionEnd, MCP events, Tab events and workspaceOpen, and the earlier lesson in this chapter found the stop hook didn't fire in print mode at all on this build — so even if you have a guard working locally, it might not be there everywhere your team runs the agent.
In Antigravity CLI
Antigravity CLI puts all hooks into a single hooks.json file under the .agents/ customisation root, so the registration is stored alongside the code. This way, it's more suitable for teams. For example, you can have the following:
{
"commit-guard": {
"PreToolUse": [
{ "matcher": "run_command",
"hooks": [ { "type": "command",
"command": "../scripts/hooks/antigravity-deny.sh", "timeout": 30 } ] }
]
},
"repo-checks": {
"Stop": [
{ "type": "command", "command": "../scripts/hooks/check.sh verify" }
]
}
}It might look confusing at first — the top-level keys are hook names, with the events nested underneath — but it's actually a better solution for teams. Each hook is named, so you can refer to it during code review or in onboarding documentation, and a developer can find the one they need by looking for its name.
Note that the two events aren't shaped the same. Tool-specific events — PreToolUse and PostToolUse — have to wrap their handlers in a group with a matcher regex; Stop, PreInvocation and PostInvocation take a flat list of handlers and ignore the matcher entirely. Copy the grouped form onto Stop and you've written something that doesn't do what it looks like it does.
The ../ in the commands above is not a typo, and it's the most probable reason why a colleague's setup doesn't work. Antigravity CLI sets the working directory to the folder containing hooks.json when running a hook command, so to reach scripts in your repo you need to go up one level.
In contrast, other tools resolve the commands differently:
Claude Code and Codex run the handlers in the session's working directory, which is why they reach for
$CLAUDE_PROJECT_DIRand$(git rev-parse --show-toplevel)respectivelyCursor runs them relative to whichever hooks file declares the hook
So if your configuration works locally but doesn't work for a colleague, make sure to check the working directory first — these tools give three different answers.
This is also the one case where guardrails.py report will happily go green while the hook is broken: the path is there in the configuration, so the report is satisfied whether or not the hook can actually reach the script from .agents/.
Antigravity CLI has also got its own advantages that come with this structure:
Multiple named hooks on the same event are merged and run one after another, so you can have a plugin's linter and your own guard in a defined order rather than racing
Setting
"enabled": falseon a named hook disables all of its handlers, which is the per-guard off switch Claude Code explicitly doesn't have — so "drop the commit guard, keep the tests" is one line here
The PreToolUse guard returns a JSON object on stdout that contains the decision (allow/deny/ask/force_ask) and an optional reason. The documentation describes only this object, with no exit-code alternative like in the other tools, so it's the one tool that needs a wrapper around the shared guard — antigravity-deny.sh in the block above:
#!/bin/sh
# Antigravity decides on a JSON object, not an exit code — so translate the guard's exit 2.
payload=$(cat)
reason=$(printf '%s' "$payload" | python3 ../scripts/commit-guard.py 2>&1) && {
printf '{"decision":"allow"}\n'; exit 0
}
printf '%s' "$reason" | python3 -c 'import json,sys; print(json.dumps({"decision":"deny","reason":sys.stdin.read().strip()}))'Run it from .agents/ and it outputs {"decision": "deny", "reason": "Use \git push --force-with-lease` instead of `--force`, …"}if you try to force-push, and{"decision":"allow"}when runningnpm test`. It's best to commit the wrapper next to the guard so everybody in the team uses it consistently — one extra file, and only here.
But the main thing is, two of your six teammates might be using a different Google tool. Google discontinued the consumer-tier service for Gemini CLI in June 2026 and redirected its users to Antigravity CLI which is a separate project, not a renaming:
Different configuration file
Different tool identifiers
Different refusal contract
So if somebody tells you they're using "Google", make sure to ask them what binary they actually use.
In Kimi Code CLI
This is the harness where the team half of the problem can't be solved from within the repository, and it's better to be explicit about that than to work around it. The hooks are a flat array of TOML tables in ~/.kimi-code/config.toml, and only the user-level path is documented — so the scripts commit and the registration doesn't. For example:
[[hooks]]
event = "PreToolUse"
matcher = "Bash"
command = "python3 ./scripts/commit-guard.py"
timeout = 15
[[hooks]]
event = "Stop"
command = "./scripts/hooks/check.sh verify"
timeout = 120There are two entries in there:
a
PreToolUsehook matched onBash, running the commit guard with a 15s timeouta
Stophook running the verify check with a 120s timeout
These work in all of your checkouts, because a hook command runs with the session's project directory as its working directory, so the relative paths resolve. That's the one thing in your favour here: six people register these two tables once, and the paths keep working in every repository that carries the scripts.
Before you change anything in config.toml, point KIMI_CODE_HOME at a throwaway directory so you're not editing the real one, and run kimi doctor afterwards — the schema is strict in a way that punishes exactly the person trying to be helpful.
Here's the note we'd naturally add when writing this config for five other people, and what kimi doctor makes of it:
$ kimi doctor config /tmp/kimi-home-3IlR/config.toml
Kimi doctor found 1 issue.
ERROR config.toml /tmp/kimi-home-3IlR/config.toml
Invalid configuration in /tmp/kimi-home-3IlR/config.toml.
Validation issues:
hooks[1]: Unrecognized key: "description"The offending line was description = "the team set", and the path is a scratch KIMI_CODE_HOME rather than the real config, which is how you'd want to reproduce this. That one key rendered the entire config.toml useless, not just the single rule — it's not one hook lost, it's every hook lost. So again, after every modification make sure to run kimi doctor: it's the surface that tells you, and you have to go and ask it.
There are three events which can block:
PreToolUseUserPromptSubmitStop
PostToolUse isn't one of them — the formatter still runs there, but the linter wouldn't be able to stop anything, which is why the set puts linting on the Stop event. The Moonshot docs are very clear that hooks are "suitable for alerts and lightweight interception, but should not be used as the sole security barrier", pointing at permission approvals for anything genuinely high-risk.
On a team level this is where guardrails.py report comes in handy — it's the only harness with no committable registration at all, so a clean checkout tells you nothing about whether a given person has a gate unless you look at their config, which is the one thing the report does.
The first week
So don't install all three at once — you won't know which latency increase or silent fail-open is caused by what. Instead:
Register a single gate (the pre-tool one) in your harness somehow and see it in action by running a session and trying to make it do sth it shouldn't;
Add the guardrails script with cases for this single gate and purposefully break it, so you can see it failing;
Set up CI to run the check mode of this script, so you have a process that runs the guards' tests;
Add the stop hook after you've been using it on your machine for a few days and are happy with it (this is the only one that modifies the session experience, so you need to get used to it yourself before showing it to others);
Run the report mode once on each machine as a group — a 15-minute chat and the only point when you're all on the same page
And the set you end up with won't be the same as ours, and shouldn't be — ours has four rules in the guard for the pre-tool event because those were the four things we'd already had to revert by hand.
scripts/guardrails.py#!/usr/bin/env python3
"""The guardrail set this repo runs, and two ways to check it is still there.
python3 scripts/guardrails.py check every guard still refuses what it must
python3 scripts/guardrails.py report which harness config on this machine registers it
`check` needs no harness, no network and no model, so it belongs in CI next to the tests.
`report` only reads config files. Neither one can tell you a harness actually loaded a
hook — for that you have to watch one refuse something in a real session.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
PROCEED, REFUSE = 0, 2
def pre_tool(command):
return {"cwd": os.getcwd(), "tool_input": {"command": command}}
def stop(mid_loop=False):
return {"cwd": os.getcwd(), "stop_hook_active": mid_loop}
# The set. One row per guard, one case per thing you would want to hear about if it broke.
#
# Every case has to hold in any checkout, on any branch, or this fails in CI for reasons
# that have nothing to do with the guards. That rules out the cases you most want to add:
# `git commit -m wip` is refused on main and allowed on a topic branch, and `.env` only
# trips the guard in a tree that has one.
GUARDS = (
("commit-guard", ("python3", "scripts/commit-guard.py"), (
("lets an ordinary command past", pre_tool("npm test"), PROCEED),
("refuses --no-verify", pre_tool("git commit --no-verify -m wip"), REFUSE),
("refuses the clustered -n", pre_tool("git commit -nm wip"), REFUSE),
("refuses a bare force push", pre_tool("git push --force origin HEAD"), REFUSE),
("allows --force-with-lease", pre_tool("git push --force-with-lease origin HEAD"), PROCEED),
)),
("check.sh", ("scripts/hooks/check.sh", "verify"), (
("stands down mid-loop", stop(mid_loop=True), PROCEED),
)),
)
# Where each harness keeps hook registration — the layers a repo or a person can own.
# Managed and enterprise layers are deliberately absent: they are not yours to read.
CONFIGS = (
("Claude Code", (".claude/settings.json", ".claude/settings.local.json",
"~/.claude/settings.json")),
("Codex CLI", (".codex/hooks.json", ".codex/config.toml",
"~/.codex/hooks.json", "~/.codex/config.toml")),
("Copilot CLI", (".github/hooks", ".github/copilot/settings.json",
"~/.copilot/settings.json")),
("Cursor", (".cursor/hooks.json", "~/.cursor/hooks.json")),
("Antigravity CLI", (".agents/hooks.json",)),
("Kimi Code CLI", ("~/.kimi-code/config.toml",)),
)
def script_of(run):
"""The path a config file would name to register this guard."""
return next(arg for arg in run if "/" in arg)
def run_case(run, payload):
done = subprocess.run(run, input=json.dumps(payload), capture_output=True, text=True)
return done.returncode, (done.stderr or done.stdout).strip().splitlines()
def check():
failures = 0
for name, run, cases in GUARDS:
if not Path(script_of(run)).exists():
print(f" MISSING {name}: {script_of(run)} is not in this checkout")
failures += 1
continue
for label, payload, expected in cases:
got, said = run_case(run, payload)
ok = got == expected
failures += not ok
print(f" {'ok ' if ok else 'FAIL'} {name}: {label}"
+ ("" if ok else f" — wanted exit {expected}, got {got}"))
if not ok and said:
print(f" said: {said[0]}")
print(f"\n{'ok' if not failures else f'{failures} failing'}"
f" — {sum(len(cases) for _, _, cases in GUARDS)} cases")
return 1 if failures else 0
def config_text(location):
path = Path(location).expanduser()
if path.is_dir():
return "".join(f.read_text(errors="replace") for f in sorted(path.glob("*.json")))
return path.read_text(errors="replace") if path.is_file() else None
def report():
for harness, locations in CONFIGS:
registered, where = set(), []
for location in locations:
text = config_text(location)
if text is None:
continue
names = {name for name, run, _ in GUARDS if script_of(run) in text}
if names:
where.append(location)
registered |= names
missing = [name for name, _, _ in GUARDS if name not in registered]
if not where:
print(f" {harness:16} nothing registered — the whole set is missing here")
else:
print(f" {harness:16} {', '.join(sorted(registered))} ({', '.join(where)})"
+ (f"\n {'':16} missing: {', '.join(missing)}" if missing else ""))
print("\nA path in a config file is not a hook that fired. This says nothing about which\n"
"event the guard is bound to, and nothing about the managed layers you can't read.")
return 0
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else ""
if mode not in ("check", "report"):
sys.exit(__doc__)
sys.exit(check() if mode == "check" else report())