Skip navigation

PublicDeep dive

Guardrails in Claude Code and AgentKit (1/2): the hook mechanism and config layers

Part 1/2: a prompt instruction is only a suggestion. A real guardrail is a hook in the Claude Code harness that blocks a tool call with exit 2. The hook mechanism, AgentKit's seven guardrail groups and its settings layers.

15 min read · v1.0.0 · Updated 09/06/2026

VI · Guardrail trong Claude Code và AgentKit (1/2): cơ chế hook và các lớp cấu hình · Read in Vietnamese

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 actionWithout guardrailsWith guardrails
Overly broad globContext fills with junkscout-block returns exit 2
Reading .envSecret ends up in the transcriptprivacy-block asks the user
Cleanup outside the scopeReview gets dilutedRules and hard-gates nudge it back to the main task
Tests pass, so keep goingNo scope checksimplify-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

GuardrailInstruction
Where it runsIn the harness, outside the modelIn the context, read by the model
EnforcementCode actually blocksThe model complies voluntarily
Long contextStill runsEasy to forget or drift
Model misunderstandsStill blocks (unless the hook crashes)Drifts with the model
Changing itEdit code or configEdit 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.

One tool call through the guardrail: the user sends a prompt through the UserPromptSubmit hook to the model; the model sends a tool call to the PreToolUse hook; PreToolUse returns an exit code that splits four ways: exit 0 lets the tool run, exit 1 or another code and a crashed hook still let the tool run (the trap), exit 2 or a JSON deny blocks it and sends stderr back to the model; after the tool comes PostToolUse, and Stop and SubagentStop run at session end
Only exit 2 or a JSON deny really blocks. exit 1 and a crashed hook both let the tool run.

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 codeDoes the tool run?What happens
exit 0YesSuccess. Stdout is read as JSON output.
exit 2No, blockedStdout is ignored, and stderr is sent back to the model as the error message.
exit 1 or any other codeYesAn 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.

GroupMechanismExampleEnforcement
Block files and pathsPreToolUsescout-block, privacy-blockCode, fail-open
Block out-of-order stepsUserPromptSubmitsimplify-gateCode
Add contextUserPromptSubmitdev-rules-reminderCode
Keep names descriptivePre, Post, Stopdescriptive-nameCode, reminder only
Hard-gate in skillsXML in markdown<HARD-GATE>Instruction
Rules as instructionsCLAUDE.mdreview-auditInstruction
Guard skillsInvoked by the userak-security, ak-shipUser

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

SituationLayer that handles it
Reading node_modules/react/scout-block, exit 2
Reading .env to check a keyprivacy-block, exit 2 plus an approval prompt
Glob **/*.ts at the rootscout-block, broad-pattern block
Prompt asks to ship when the diff has balloonedsimplify-gate, if gate.enabled=true
Starting to code with no plan or reviewak-cook HARD-GATE
Changing a threshold the user already settledreview-audit rule: ask again before changing
A new file with a vague namedescriptive-name, PreToolUse(Write)
A plan using links or text in the wrong formatplan-format-kanban, PostToolUse(Edit/Write/MultiEdit)
Two teammates editing the same fileteam-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.

text
# Global scope
~/.claude/settings.json
~/.claude/.ck.json
~/.claude/hooks/*.cjs

# Project scope
.claude/settings.json
.claude/.ck.json
.claude/hooks/*.cjs

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

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:

LayerWhat it means when auditing
Script fileThe .cjs file exists in .claude/hooks/. Having the file does not mean the hook runs.
Wiredsettings.json attaches the script to a Claude Code lifecycle event. If it is not wired, Claude Code never calls it.
Runtime flagOnce 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.

HookEventStatus after installHow to trigger it
session-initSessionStartWired, flag true. Sets up the project and environment on startup, resume, clear, compactOpen, resume, clear or compact a session (SessionStart(startup|resume|clear|compact))
usage-quota-cache-refreshSessionStart, UserPromptSubmit, PostToolUseWired, reads no flag of its own, runs whenever wired. Caches usageOpen a session, send a new prompt, or update Task/Todo
simplify-gateUserPromptSubmitScript is called (flag true), but simplify.gate.enabled defaults to false, so it does not blockSend a prompt that intends to ship, merge, pr, deploy or publish when the diff is large enough
dev-rules-reminderUserPromptSubmitWired, flag true. Injects dev rules and contextSend a new prompt. Rules are injected on a TTL
subagent-initSubagentStartWired, flag true. Injects context for subagentsStart a subagent through Task or an agent flow
descriptive-namePreToolUse(Write)Wired, flag true. Reminds you to name files and scripts clearlyLet Claude create a file with Write
scout-blockPreToolUse on Bash, Glob, Grep, Read, Edit, WriteWired, flag true. Blocks heavy directories and overly broad globsLet Claude read node_modules, dist, or run an overly broad glob
privacy-blockPreToolUse on Bash, Glob, Grep, Read, Edit, WriteWired, flag true, plus privacyBlock set to true. Blocks secret paths and asks for approvalLet Claude read .env, a key file, or a secret path
plan-format-kanbanPostToolUse(Edit/Write/MultiEdit)Wired, reads no flag of its own. Warns about plan formattingLet Claude edit or write a plan file
session-statePostToolUse, SubagentStop, StopWired, flag true. Records session and task stateUpdate Task/Todo, end a subagent, or end a turn
cook-after-plan-reminderSubagentStop(Plan)Wired. No dedicated key in DEFAULT_CONFIG.hooks, but isHookEnabled() only disables when the flag is false, so a missing key means enabledLet the Plan subagent finish
workflow-artifact-gateNot wiredFlag false. Opt-in: wire the hook and enable the flag and gate configWire it to UserPromptSubmit or PreToolUse(Bash), or run the script with --stage. A fresh install does not trigger hook mode
task-completed-handlerTaskCompletedFor Agent Teams when a task completes. Slated for removalComplete a task in Agent Teams
teammate-idle-handlerTeammateIdleFor Agent Teams when a teammate goes idle. Slated for removalLet a teammate in Agent Teams run out of work and go idle
team-context-injectNot wiredMissing key means enabled. Only matters when the workflow or team layer calls itWire it to SubagentStart, then start a team subagent. The script only matters when the agent id belongs to a team
usage-context-awarenessNot wiredFlag true. A legacy wrapper around the usage quota cache refresh, not in the settings.json templateWire 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>.

json
// .claude/.ck.json or ~/.claude/.ck.json
{
  "hooks": {
    "scout-block": false,
    "privacy-block": true
  },
  "privacyBlock": true,
  "simplify": {
    "gate": {
      "enabled": true
    }
  }
}
GoalWhere to change itNote
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 globallyProject .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 scopesettings.json: { "env": { "CK_SIMPLIFY_DISABLED": "1" } }The environment variable overrides the gate config.
Enable workflow-artifact-gatesettings.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.

json
// ~/.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 key

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

Paste into your agent
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)
terminal
claude mcp add --transport http modernengineer \
  https://api.modernengineer.dev/mcp \
  --header "Authorization: Bearer <API key>"
How to connect Codex, Cursor and other clients

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