Interactive version
This piece comes with an interactive page: diagrams, scenarios and sample sessions. The link is created fresh on every click.
Quick summary: An instruction in the prompt is only a suggestion; the model can forget it or drift. A real guardrail is a hook running in the Claude Code harness that checks each tool call before it runs and blocks it with
exit 2. This post explains that mechanism, then takes apart each guard layer in AgentKit: which ones block in code, which ones are only instructions, and the gaps you should check before trusting them.
When an agent can read files, run shell commands, edit code and open PRs, one line of instruction in the prompt is not enough. You need a check that sits in front of the moment the tool runs.
Disclosure: I maintain AgentKit and have an affiliate relationship with it: the AgentKit link in this post is an affiliate link; buying through it gets you 30% off.
Note: the current AgentKit beta (since 3.0.0-beta.35, October 21, 2026) changed skill names from ak:<name> to ak-<name>, and the commands in this post use the new names: Claude Code calls /ak-plan, Codex calls $ak-plan. On an older version, swap ak- back to ak: to get the matching command.
AgentKit is the new name for ClaudeKit. The configuration in this post is a snapshot of claudekit-engineer@2.19.1 as of when I wrote it (June 2026), so file names (.ck.json, .ckignore), hook names, keys and environment variables (CK_*) are kept as they were in that version. When I reviewed it again against AgentKit 3.0.0-beta.1 (October 2026), commands had moved to the /ak: prefix, .ck.json and CK_SIMPLIFY_DISABLED were still read (the migration docs mention an optional move to config.yaml, but the installed hooks still read .ck.json), and the workflow-artifact-gate hook was no longer in a fresh install. I keep the section on that gate because it shows how artifact checks work, but check your .claude/hooks/ folder before enabling it. I explain the general Claude Code mechanism first, then go through AgentKit's guard layers one by one.
A small task, far too many permissions
You give an agent a small job: fix the input validation in one module. To be thorough, it scans a few extra folders, opens a sensitive config file, and while it is at it, bundles some unrelated cleanup into the same PR.
At a glance everything looks fine, and the tests pass. But the context is now full of junk files, a secret may have landed in the transcript (the full record of the conversation between you and the agent), and the review is diluted because the needed change and the "while I'm at it" changes sit in the same PR.
Compare the two cases:
| Agent action | Without guardrails | With guardrails |
|---|---|---|
| Overly broad glob | Context fills with junk | scout-block returns exit 2 |
Reading .env | Secret ends up in the transcript | privacy-block asks the user |
| Cleanup outside the scope | Review gets diluted | Rules and hard-gates nudge it back to the main task |
| Tests pass, so keep going | No scope check | simplify-gate or a review gate rechecks the diff before shipping |
The agent does not become more careful on its own. It is just that before it does something, a layer steps in and asks: should this be allowed to run?
The core idea of this post: hooks are the real blocking layer. A guardrail does not make the model smarter; it checks the action before the tool runs. Rules, hard-gates and guard skills are extra steering layers, useful but still dependent on the model or the user following or invoking them.
What a guardrail is in agentic coding
A guardrail is the layer between the agent and its actions: risky actions get blocked, suspicious ones get flagged. Think of the railing at the edge of a staircase: it does not make you a better walker, but it lowers the chance that a misstep turns into an accident. Unlike an instruction in the prompt, a guardrail runs at the tool orchestration layer and does not depend on whether the model "remembers".
Guardrails versus instructions
| Guardrail | Instruction | |
|---|---|---|
| Where it runs | In the harness, outside the model | In the context, read by the model |
| Enforcement | Code actually blocks | The model complies voluntarily |
| Long context | Still runs | Easy to forget or drift |
| Model misunderstands | Still blocks (unless the hook crashes) | Drifts with the model |
| Changing it | Edit code or config | Edit the text |
How guardrails work
The harness is the runtime layer wrapped around the model. The model only decides "I want to read this file" or "I want to run this command". The harness is what receives the tool call, manages permissions, calls hooks, lets the tool run or blocks it, and returns the result to the model. A hook is a small script the harness calls at fixed points in a tool call's lifecycle (when a prompt arrives, before the tool runs, after it runs) to step in and check. Since every real action goes through the harness, that is where guardrails attach.
For background reading: Harness Engineering là gì? by Duy /zuey/ (in Vietnamese).
Where hooks sit in the Claude Code lifecycle
Hooks do not live in the model. They live in the Claude Code lifecycle: settings.json decides which hooks get called, while .ck.json and environment variables decide what a hook does when it runs. A hook's output can allow, block, inject extra context, or record state and output files after a tool or session finishes. For more on AgentKit's hooks, see the VividKit Guides.
A tool call passing through a guardrail
The key point: a hook that runs before the tool returns an exit code. exit 0 lets the tool run, exit 2 blocks it and pushes the reason back to the model. The three main hook points have matching technical names: UserPromptSubmit (when a prompt arrives), PreToolUse (before the tool), PostToolUse (after the tool). Stop and SubagentStop run when a session or subagent finishes.
An exit code is the number a process returns when it ends. According to the Claude Code hooks docs, Claude Code reads that number to decide whether the tool runs:
| Exit code | Does the tool run? | What happens |
|---|---|---|
exit 0 | Yes | Success. Stdout is read as JSON output. |
exit 2 | No, blocked | Stdout is ignored, and stderr is sent back to the model as the error message. |
exit 1 or any other code | Yes | An error, but not a block. The error is reported and the tool still runs. |
Among exit codes, only exit 2 really blocks. A hook that enforces a policy through its exit code must use exactly exit 2. Use exit 1 out of Unix habit and the guard looks enabled while actually staying open. The other way to block is exit 0 with JSON on stdout: for PreToolUse, returning hookSpecificOutput.permissionDecision set to "deny" also blocks the tool, with the reason in permissionDecisionReason (PreToolUse decision control).
Two kinds of blocking
Hard blocking in code. The hook returns exit 2, or a deny decision in JSON, and the tool does not run. But if the hook crashes, Claude Code lets the tool run anyway, which is called fail-open. A bug in a hook silently turns that guard off.
Soft blocking in text. Text is added to the context (rules, hard-gates) and the model has to comply on its own. How strong it is depends on the model.
Splitting guardrails into several layers brings a few benefits:
- Keeps the context clean and secrets out of the transcript.
- Keeps the working rhythm: plan, simplify, then review.
- Blocks early, before risk spreads, when the agent is about to read
.env, scan too broadly, or pull out-of-scope cleanup into a PR. - Layers cover for each other: hooks can fail open, rules can be ignored by the model, guard skills may never be invoked. Several layers mean less dependence on any single point of control.
Seven guardrail groups in AgentKit
After a fresh install, AgentKit's file-access guards sit in PreToolUse hooks such as scout-block and privacy-block. If your Claude Code session runs in a mode that skips permission prompts, these hooks are still the main blocking layer. But AgentKit hooks are fail-open: if one crashes or is disabled, the tool call can go through.
| Group | Mechanism | Example | Enforcement |
|---|---|---|---|
| Block files and paths | PreToolUse | scout-block, privacy-block | Code, fail-open |
| Block out-of-order steps | UserPromptSubmit | simplify-gate | Code |
| Add context | UserPromptSubmit | dev-rules-reminder | Code |
| Keep names descriptive | Pre, Post, Stop | descriptive-name | Code, reminder only |
| Hard-gate in skills | XML in markdown | <HARD-GATE> | Instruction |
| Rules as instructions | CLAUDE.md | review-audit | Instruction |
| Guard skills | Invoked by the user | ak-security, ak-ship | User |
Each group has its own section in Part 2: file and path blocking under scout-block and privacy-block, out-of-order blocking under simplify-gate and workflow-artifact-gate, hard-gates under the hard-gate XML section, context and rules under dev-rules-reminder, and guard skills in the last section of this part. descriptive-name only appears in the hook table.
Nine familiar situations
| Situation | Layer that handles it |
|---|---|
Reading node_modules/react/ | scout-block, exit 2 |
Reading .env to check a key | privacy-block, exit 2 plus an approval prompt |
Glob **/*.ts at the root | scout-block, broad-pattern block |
| Prompt asks to ship when the diff has ballooned | simplify-gate, if gate.enabled=true |
| Starting to code with no plan or review | ak-cook HARD-GATE |
| Changing a threshold the user already settled | review-audit rule: ask again before changing |
| A new file with a vague name | descriptive-name, PreToolUse(Write) |
| A plan using links or text in the wrong format | plan-format-kanban, PostToolUse(Edit/Write/MultiEdit) |
| Two teammates editing the same file | team-coordination rule, Agent Team only |
Settings and config layers
This is where you check whether a hook actually runs. Two config layers decide which guards run: settings.json attaches hooks to the Claude Code lifecycle, and .ck.json turns each hook on or off and tunes thresholds.
# Global scope
~/.claude/settings.json
~/.claude/.ck.json
~/.claude/hooks/*.cjs
# Project scope
.claude/settings.json
.claude/.ck.json
.claude/hooks/*.cjssettings.json: which hook runs at which point
The snippet below only shows a PreToolUse slice of a fresh install: AgentKit ships scout-block and privacy-block and wires them into the lifecycle so Claude Code calls them before a tool runs. The full hook config on an installed machine covers more events; the place to look is Claude Code's settings.json.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Glob|Grep|Read|Edit|Write",
"hooks": [
{ "type": "command", "command": "node \".claude/hooks/scout-block.cjs\"" },
{ "type": "command", "command": "node \".claude/hooks/privacy-block.cjs\"" }
]
}
]
}
}settings.json is the lifecycle map: which hook runs when the user submits a prompt, which one is called before a tool runs, which one handles things after the tool finishes. For a global install, the CLI rewrites relative commands in the template into the form node "$HOME/.claude/hooks/scout-block.cjs". For a project install, paths usually stay as .claude/hooks/....
Reading hook status in three layers
Do not lump everything into "on by default" or "off by default". A hook's status has three separate layers:
| Layer | What it means when auditing |
|---|---|
| Script file | The .cjs file exists in .claude/hooks/. Having the file does not mean the hook runs. |
| Wired | settings.json attaches the script to a Claude Code lifecycle event. If it is not wired, Claude Code never calls it. |
| Runtime flag | Once the hook is called, its code reads .ck.json, DEFAULT_CONFIG or environment variables to decide whether to continue. Some guards also have sub-switches such as privacyBlock or simplify.gate.enabled. |
The table below is a snapshot of the hook list in claudekit-engineer@2.19.1. The last column is how to trigger each hook to check it yourself. When I wrote this, task-completed-handler and teammate-idle-handler were slated for removal in the next release, so do not treat them as long-lived hooks.
| Hook | Event | Status after install | How to trigger it |
|---|---|---|---|
session-init | SessionStart | Wired, flag true. Sets up the project and environment on startup, resume, clear, compact | Open, resume, clear or compact a session (SessionStart(startup|resume|clear|compact)) |
usage-quota-cache-refresh | SessionStart, UserPromptSubmit, PostToolUse | Wired, reads no flag of its own, runs whenever wired. Caches usage | Open a session, send a new prompt, or update Task/Todo |
simplify-gate | UserPromptSubmit | Script is called (flag true), but simplify.gate.enabled defaults to false, so it does not block | Send a prompt that intends to ship, merge, pr, deploy or publish when the diff is large enough |
dev-rules-reminder | UserPromptSubmit | Wired, flag true. Injects dev rules and context | Send a new prompt. Rules are injected on a TTL |
subagent-init | SubagentStart | Wired, flag true. Injects context for subagents | Start a subagent through Task or an agent flow |
descriptive-name | PreToolUse(Write) | Wired, flag true. Reminds you to name files and scripts clearly | Let Claude create a file with Write |
scout-block | PreToolUse on Bash, Glob, Grep, Read, Edit, Write | Wired, flag true. Blocks heavy directories and overly broad globs | Let Claude read node_modules, dist, or run an overly broad glob |
privacy-block | PreToolUse on Bash, Glob, Grep, Read, Edit, Write | Wired, flag true, plus privacyBlock set to true. Blocks secret paths and asks for approval | Let Claude read .env, a key file, or a secret path |
plan-format-kanban | PostToolUse(Edit/Write/MultiEdit) | Wired, reads no flag of its own. Warns about plan formatting | Let Claude edit or write a plan file |
session-state | PostToolUse, SubagentStop, Stop | Wired, flag true. Records session and task state | Update Task/Todo, end a subagent, or end a turn |
cook-after-plan-reminder | SubagentStop(Plan) | Wired. No dedicated key in DEFAULT_CONFIG.hooks, but isHookEnabled() only disables when the flag is false, so a missing key means enabled | Let the Plan subagent finish |
workflow-artifact-gate | Not wired | Flag false. Opt-in: wire the hook and enable the flag and gate config | Wire it to UserPromptSubmit or PreToolUse(Bash), or run the script with --stage. A fresh install does not trigger hook mode |
task-completed-handler | TaskCompleted | For Agent Teams when a task completes. Slated for removal | Complete a task in Agent Teams |
teammate-idle-handler | TeammateIdle | For Agent Teams when a teammate goes idle. Slated for removal | Let a teammate in Agent Teams run out of work and go idle |
team-context-inject | Not wired | Missing key means enabled. Only matters when the workflow or team layer calls it | Wire it to SubagentStart, then start a team subagent. The script only matters when the agent id belongs to a team |
usage-context-awareness | Not wired | Flag true. A legacy wrapper around the usage quota cache refresh, not in the settings.json template | Wire it to the event you want, or rely on the cache refresh hook that is already wired |
Easy to miss: isHookEnabled() only disables when hooks.<name> is false; a missing key is usually treated as enabled. But some guards have their own switches: privacy-block still reads the old key privacyBlock=false, and simplify-gate has simplify.gate.enabled plus the environment variable CK_SIMPLIFY_DISABLED=1.
Turning hooks on and off and checking them yourself
For a quick change, edit .ck.json at the scope you want to affect. Project config takes priority over global config when a key is set explicitly; a missing key inherits or falls back to the default. For hooks already wired in settings.json, false turns one off, and true turns it back on after a higher scope disabled it. The hook name is also the key under hooks.<name>.
// .claude/.ck.json or ~/.claude/.ck.json
{
"hooks": {
"scout-block": false,
"privacy-block": true
},
"privacyBlock": true,
"simplify": {
"gate": {
"enabled": true
}
}
}| Goal | Where to change it | Note |
|---|---|---|
| Disable a wired hook | .ck.json: {"hooks":{"scout-block":false}} | The script is still there, but it lets everything through at runtime. |
| Re-enable a hook disabled globally | Project .ck.json: {"hooks":{"scout-block":true}} | An explicit project key overrides global. |
| Disable the secrets guard | .ck.json: {"hooks":{"privacy-block":false}}, or the old key {"privacyBlock":false} | privacyBlock=false is an old key but still takes effect. |
Make simplify-gate actually block | .ck.json: { "hooks": { "simplify-gate": true }, "simplify": { "gate": { "enabled": true } } } | Without simplify.gate.enabled=true, the gate is not in blocking mode. |
Disable simplify-gate per session or scope | settings.json: { "env": { "CK_SIMPLIFY_DISABLED": "1" } } | The environment variable overrides the gate config. |
Enable workflow-artifact-gate | settings.json: wire to UserPromptSubmit and PreToolUse(Bash); .ck.json: {"hooks":{"workflow-artifact-gate":true}} | A fresh install does not wire this hook, so editing .ck.json alone is not enough. |
To know whether a hook is triggered, look at the lifecycle event, not the file name. A script only runs when the event and matcher in settings.json match the current action. A hook that is not wired needs wiring first, or a direct CLI call if the script supports it.
// ~/.claude/settings.json or .claude/settings.json
{
"env": {
"CK_SIMPLIFY_DISABLED": "1"
}
}Read next: Part 2: each guard layer, its gaps and good habits takes apart each AgentKit guardrail layer: which ones block in code and which ones are only instructions.
Use this piece in your agent
Get an API keyCopy the text below and paste it into Claude Code, Codex or whichever agent you use. The agent reads the piece and checks it against the repo you have open.
Read "Guardrails in Claude Code and AgentKit (1/2): the hook mechanism and config layers" on modernEngineer and apply it to this repo.
1. Call the content_get tool of the modernengineer MCP server with locale "en", slug "guard-rails-in-claude-code-and-claudekit". If MCP is not connected or access is refused, read the page https://modernengineer.dev/en/library/guard-rails-in-claude-code-and-claudekit instead.
2. Summarize the main steps of the piece and point out which apply to this repo.
3. Propose concrete changes and ask me before editing any file.Connect MCP in Claude Code (once)
claude mcp add --transport http modernengineer \
https://api.modernengineer.dev/mcp \
--header "Authorization: Bearer <API key>"This piece is open to everyone: without MCP, the agent can still read it straight from the link.
- Version
- v1.0.0
- Updated
- 09/06/2026
- Licence
- All rights reserved
- Slug
- guard-rails-in-claude-code-and-claudekit


