OneLinersCommand workbench
Guides
AI/ML / Software Engineering / Security

Automate Claude Code safely with deterministic hooks

Build a complete Claude Code hook suite that blocks protected edits before execution, formats only reviewed source files after successful edits, emits privacy-safe local notifications, records sanitized configuration-change evidence, survives cwd changes, and has deterministic tests plus an emergency disable path.

200 min11 stepsChanges system stateRevision 1
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 11 steps completed
Goal

Deliver project hooks whose commands are fixed, inputs are bounded and treated as hostile, paths are canonicalized, subprocesses never use shell interpolation, protected writes fail closed, non-blocking events are described honestly, audit records exclude prompts and contents, and every hook can be tested or disabled without asking the failing hook to repair itself.

Supported environments
  • Claude Code 2.1.220 or later
  • Node.js 22 LTS
  • Prettier 3.6.2
  • Git 2.50 or later
Prerequisites
  • Reviewed repository checkout Use a disposable branch or worktree with no production credentials and retain an external editor or terminal capable of disabling hooks if Claude Code becomes unusable.git status --short && git rev-parse --show-toplevel
  • Pinned local formatter Install the exact Prettier version from the reviewed lockfile. The hook must never use `npx` in download mode or a formatter selected from user input.npm ls prettier
  • Per-event behavior table Decide which events may block, which run after an irreversible action, what exit 2 means for each event, timeout behavior, sanitized observability, and the owner of false-positive recovery.
Operating boundary

OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.

Full guide

What you will build

System
  • A deterministic PreToolUse path gate that blocks protected built-in edits before execution and rejects malformed or escaping input.
  • A bounded PostToolUse formatter, local redacted notifier, and sanitized ConfigChange evidence writer with fixed executable paths and timeouts.
  • A complete synthetic harness and live validation matrix plus a documented external emergency-disable workflow.
Observable outcome
  • Protected edits fail before execution while an allowed source edit reaches the normal permission flow.
  • Formatting runs only for the exact supported changed file and never downloads code or evaluates a user-controlled shell command.
  • Notifications and audit records contain reviewed labels only, with no prompts, raw messages, transcripts, contents, secrets, session IDs, or full personal paths.
  • A false block, crash, timeout, or malformed input has an explicit visible result and a tested recovery path.

Architecture

How the parts fit together

Claude Code emits typed lifecycle JSON. Narrow event matchers select one fixed Node script using project-root args. PreToolUse parses and canonicalizes a proposed file path before returning a blocking exit. PostToolUse formats an already changed allowlisted file and returns bounded context. Notification maps event type to a local fixed label. ConfigChange appends minimal evidence to an ignored file. A separate harness invokes the same scripts with synthetic inputs, while `/hooks` and live fixtures verify discovery and event timing.

Event matcherLimits each handler to reviewed Claude Code event and tool classes.
Protected-path gateCanonicalizes a proposed file target and exits 2 before a protected Edit or Write.
Formatter runnerExecutes pinned local Prettier with explicit argv for one allowlisted changed file.
Privacy-safe side effectsEmit fixed local notifications and append minimal configuration-change evidence.
Contract and live testsProve script behavior, active discovery, timing, redaction, and emergency recovery.
  1. Claude Code matches a lifecycle event and starts the fixed command with JSON on stdin.
  2. The script bounds and parses only fields required for its documented decision.
  3. PreToolUse either exits 2 with a normalized reason or stays silent so normal permission evaluation continues.
  4. After a successful edit, PostToolUse formats only a reviewed path and returns short context; it never claims rollback.
  5. Notification and ConfigChange produce fixed or sanitized local evidence without copying raw input.
  6. Contract and live fixtures compare the observed exit, file, output and audit state with release gates.

Assumptions

  • Node.js and the exact pinned local formatter are installed from a reviewed lockfile before hooks are enabled.
  • The repository root is supplied by `${CLAUDE_PROJECT_DIR}` and the scripts run on a filesystem where canonical parent resolution exposes symlink escapes.
  • The project has an external editor or administrator path that remains available when hook configuration blocks ordinary Claude Code work.
  • The audit directory is local, ignored, access-restricted, and governed by a documented retention policy.
  • Contributors review project hooks before accepting workspace trust because command hooks run with their full user privileges.

Key concepts

PreToolUse
Lifecycle event before a tool call. A command hook exit of 2 can block the proposed call.
PostToolUse
Lifecycle event after a successful tool call. It can provide feedback or update output but cannot make the original edit not happen.
Matcher
Declarative filter selecting tool or event classes before Claude Code starts a hook handler.
Project-root placeholder
Claude Code substitution used in hook args so a script path does not depend on the current working directory.
disableAllHooks
Settings switch that disables hooks at editable scopes; only managed policy can disable managed hooks.

Before you copy

Values used in this guide

{{protectedPaths}}

Repository-relative prefixes blocked by the deterministic PreToolUse path gate.

Example: .git,.github/workflows,.claude,secrets,.env
{{formatterExtensions}}

Supported extensions eligible for the pinned formatter after an edit.

Example: .js,.mjs,.cjs,.ts,.tsx,.json
{{auditRetentionDays}}

Locally approved retention for sanitized hook event evidence.

Example: 14

Security and production boundaries

  • Command hooks execute with the full privileges of the Claude Code user. Sandbox claims for ordinary Bash do not automatically confine hook processes.
  • Never construct a shell command from `tool_input`, a filename, notification text, prompt, transcript path, or model output.
  • A ConfigChange hook records an event but is not tamper-proof; managed policy or external monitoring is required for stronger enforcement.
  • PostToolUse runs after the tool call and cannot protect a path. Put deterministic protection in PreToolUse and retain explicit permission denies.
  • Debug logs may contain full hook stdin or stderr. Restrict access and retention, and do not attach raw logs to public issues.

Stop before continuing if

  • A protected or malformed PreToolUse input fails open.
  • A hook executes a shell string or downloads a dependency selected at runtime.
  • Notification, audit, stdout, stderr, or debug evidence contains raw prompt, content, secret, transcript, or full personal path.
  • A hook runs from an unexpected settings source or cannot be disabled by its documented owner.
  • The contract harness and live event behavior differ.
01

decision

Freeze event and failure semantics

read-only

Document PreToolUse as the only protected-edit gate in this suite, PostToolUse as feedback after an edit already happened, Notification as a local side effect, and ConfigChange as evidence rather than an immutable security boundary. Define exit codes, maximum input, timeout, redaction, and emergency disable behavior.

Why this step matters

Hook event timing determines whether a failure can prevent an action, and incorrect assumptions about exit code 2 can turn an advisory hook into a false security control.

What to understand

For PreToolUse, exit 2 blocks the tool call; for PostToolUse the edit already occurred and stderr is feedback rather than rollback.

Command hooks execute with the user's full operating-system privileges, so their scripts and dependency resolution require the same review as ordinary executable code.

System changes

  • Creates the event behavior and incident table that later tests enforce.

Syntax explained

disableAllHooks
Documented settings switch for temporarily disabling editable-scope hooks; managed hooks require a managed switch.
Example output / evidence
PreToolUse=blocking PostToolUse=after-effect Notification=non-blocking ConfigChange=observable input_max=65536B emergency=disableAllHooks

Checkpoint: Approve the event matrix

Continue whenReviewers can explain whether each hook blocks, advises, records, or performs a side effect and how its timeout behaves.

Stop whenStop if a policy depends on a non-blocking event, raw prompt logging, model judgment, or a command assembled from hook input.

If this step fails

A reviewer expects PostToolUse to undo a protected write.

Likely causeEvent timing and exit semantics were not documented.

Safe checks
  • Compare the event table with the official hooks reference

ResolutionMove the protection to PreToolUse and treat PostToolUse only as validation or feedback.

Stop conditions

  • Stop if a policy depends on a non-blocking event, raw prompt logging, model judgment, or a command assembled from hook input.
02

config

Pin the local hook runtime

caution

Create a private package with syntax and contract-test scripts and one exact formatter dependency. Commit the lockfile after review, and run installation before enabling the hooks.

Why this step matters

A fixed local executable path avoids downloading code from a hook and makes formatter behavior reproducible across contributors.

What to understand

The formatter hook calls the installed JavaScript entry with `process.execPath` and explicit argv, never a shell command derived from the file path.

The `private` flag prevents accidentally publishing the repository package while the lockfile records the reviewed dependency graph.

System changes

  • Adds package metadata and the pinned formatter dependency.

Syntax explained

prettier 3.6.2
Exact formatter revision exercised by the contract harness.
File package.json
Configuration
{
  "name": "claude-code-reviewed-hooks",
  "private": true,
  "type": "module",
  "scripts": {
    "check:hooks": "node --check .claude/hooks/protect-paths.mjs && node --check .claude/hooks/format-changed.mjs && node --check .claude/hooks/notify.mjs && node --check .claude/hooks/audit-config.mjs",
    "test:hooks": "node test/hooks-contract.mjs"
  },
  "devDependencies": {
    "prettier": "3.6.2"
  }
}
Example output / evidence
added 1 package
prettier@3.6.2
0 vulnerabilities
hook scripts: 4

Checkpoint: Install and inspect the dependency

Continue when`npm ci` installs the reviewed lock and `npm ls prettier` reports exactly 3.6.2.

Stop whenStop if installation changes an unrelated dependency, runs an unreviewed lifecycle script, or resolves a different formatter.

If this step fails

The formatter is missing when a hook runs.

Likely causeHooks were enabled before the pinned dependency installation completed.

Safe checks
  • npm ls prettier
  • Check the reviewed lockfile

ResolutionDisable hooks, restore the lockfile, run the approved installation, and rerun the contract test.

Stop conditions

  • Stop if installation changes an unrelated dependency, runs an unreviewed lifecycle script, or resolves a different formatter.
03

config

Implement the protected-path gate

caution

Create a PreToolUse command hook that bounds stdin, parses JSON fail-closed, accepts only Edit and Write, canonicalizes existing parents to catch symlink escapes, rejects paths outside the repository, and exits 2 for protected destinations.

Why this step matters

The blocking decision must be deterministic and independent of model intent because a convincing prompt or repository file cannot make a protected write safe.

What to understand

Canonicalizing the nearest existing parent prevents a new filename under a symlinked directory from escaping the reviewed root.

The hook returns only a normalized relative path in stderr and never file content, prompt text, environment values, or the transcript.

System changes

  • Adds one executable policy gate for built-in file-editing tools.

Syntax explained

exit 2
Documented blocking result for PreToolUse command hooks.
File .claude/hooks/protect-paths.mjs
Configuration
import fs from "node:fs";
import path from "node:path";

const MAX_INPUT_BYTES = 65_536;
const chunks = [];
let bytes = 0;
for await (const chunk of process.stdin) {
  bytes += chunk.length;
  if (bytes > MAX_INPUT_BYTES) {
    console.error("Blocked: hook input exceeds 64 KiB");
    process.exit(2);
  }
  chunks.push(chunk);
}

let input;
try {
  input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
  console.error("Blocked: malformed hook JSON");
  process.exit(2);
}

if (!["Edit", "Write"].includes(input.tool_name)) process.exit(0);
const rawPath = input.tool_input?.file_path;
if (typeof rawPath !== "string" || rawPath.length === 0 || rawPath.length > 4096) {
  console.error("Blocked: missing or invalid file path");
  process.exit(2);
}

const projectRoot = fs.realpathSync(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
const requested = path.resolve(projectRoot, rawPath);
const relative = path.relative(projectRoot, requested);
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
  console.error("Blocked: path resolves outside the reviewed repository");
  process.exit(2);
}

function nearestExistingRealPath(candidate) {
  let cursor = candidate;
  const suffix = [];
  while (!fs.existsSync(cursor)) {
    const parent = path.dirname(cursor);
    if (parent === cursor) break;
    suffix.unshift(path.basename(cursor));
    cursor = parent;
  }
  const realBase = fs.realpathSync(cursor);
  return path.join(realBase, ...suffix);
}

const canonical = nearestExistingRealPath(requested);
const canonicalRelative = path.relative(projectRoot, canonical);
if (canonicalRelative.startsWith("..") || path.isAbsolute(canonicalRelative)) {
  console.error("Blocked: symlink target resolves outside the reviewed repository");
  process.exit(2);
}

const normalized = canonicalRelative.split(path.sep).join("/");
const protectedPrefixes = [
  ".git",
  ".github/workflows",
  ".claude",
  "secrets",
  ".env"
];
const blocked = protectedPrefixes.some(
  (prefix) =>
    normalized === prefix ||
    normalized.startsWith(prefix + "/") ||
    (prefix === ".env" && normalized.startsWith(".env."))
);
if (blocked) {
  console.error("Blocked protected path: " + normalized);
  process.exit(2);
}

process.exit(0);
Example output / evidence
protected=.github/workflows/release.yml exit=2 stderr="Blocked protected path: .github/workflows/release.yml"
allowed=src/example.ts exit=0

Checkpoint: Exercise protected, allowed, malformed, and outside-root inputs

Continue whenProtected, malformed, and escaping inputs exit 2; a normal source path exits 0 without output.

Stop whenStop if malformed JSON fails open, a symlink escape succeeds, or the script prints raw hook input.

If this step fails

A legitimate source edit is blocked.

Likely causeThe normalized path matches an overly broad protected prefix.

Safe checks
  • Record only the normalized relative path
  • Run the same fixture through the contract harness

ResolutionDisable the suite externally, narrow the reviewed prefix, and add positive and negative regression fixtures.

Stop conditions

  • Stop if malformed JSON fails open, a symlink escape succeeds, or the script prints raw hook input.
04

config

Implement the bounded post-edit formatter

caution

Create a PostToolUse hook that formats only supported files under `src`, `test`, or the contract scratch directory. It resolves the local pinned Prettier entry and uses executable plus argv without a shell.

Why this step matters

A formatter is a state-changing subprocess with full user privileges, so both the executable and the exact target require an allowlist.

What to understand

Unsupported extensions and paths exit successfully without running a subprocess, keeping the hook fast and predictable.

A formatter failure is visible but cannot undo the original edit because PostToolUse runs after the tool call.

System changes

  • Adds deterministic formatting for reviewed source, test, and harness fixture paths.

Syntax explained

--write -- <target>
Passes one target after the option terminator so a filename cannot be interpreted as a formatter flag.
File .claude/hooks/format-changed.mjs
Configuration
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";

const input = JSON.parse(await new Response(process.stdin).text());
if (!["Edit", "Write"].includes(input.tool_name)) process.exit(0);

const projectRoot = fs.realpathSync(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
const rawPath = input.tool_input?.file_path;
if (typeof rawPath !== "string") process.exit(0);
const target = path.resolve(projectRoot, rawPath);
const relative = path.relative(projectRoot, target);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
  console.error("Formatter refused a path outside the repository");
  process.exit(1);
}

const normalized = relative.split(path.sep).join("/");
const allowed =
  ["src/", "test/", ".tmp/hooks/"].some((prefix) => normalized.startsWith(prefix)) &&
  /\.(?:js|mjs|cjs|ts|tsx|json)$/.test(normalized);
if (!allowed) process.exit(0);

const prettierCli = path.join(projectRoot, "node_modules", "prettier", "bin", "prettier.cjs");
if (!fs.existsSync(prettierCli)) {
  console.error("Formatter unavailable: run npm ci before enabling this hook");
  process.exit(1);
}

const result = spawnSync(
  process.execPath,
  [prettierCli, "--write", "--", target],
  {
    cwd: projectRoot,
    encoding: "utf8",
    timeout: 15_000,
    shell: false,
    windowsHide: true
  }
);
if (result.error || result.status !== 0) {
  console.error("Formatter failed for " + normalized);
  process.exit(1);
}

console.log(JSON.stringify({
  hookSpecificOutput: {
    hookEventName: "PostToolUse",
    additionalContext: "Formatted the changed file: " + normalized
  }
}));
Example output / evidence
hook=PostToolUse file=src/example.ts formatter=prettier@3.6.2 exit=0 context="Formatted the changed file: src/example.ts"

Checkpoint: Format a deliberately unformatted scratch fixture

Continue whenOnly the intended fixture changes and the hook emits a short additional-context message.

Stop whenStop if the hook downloads a package, invokes a shell, formats an unsupported path, or modifies more than the input file.

If this step fails

Formatting fails after a successful edit.

Likely causeThe pinned formatter is absent, timed out, or rejected the file.

Safe checks
  • Inspect the sanitized formatter exit status
  • Run the exact local Prettier entry against the scratch fixture

ResolutionKeep the original edit, report the formatter failure, disable the hook if repeated, and fix the dependency separately.

Stop conditions

  • Stop if the hook downloads a package, invokes a shell, formats an unsupported path, or modifies more than the input file.
05

config

Implement privacy-safe local notifications

caution

Create a Notification hook that maps only the documented notification type to a fixed local message and bell. It deliberately ignores the event's free-form message and title, so prompts and paths are not forwarded.

Why this step matters

Notification input can contain sensitive context; a fixed local label provides attention without sending user content to another service.

What to understand

The matcher limits execution to permission, idle, and completion notifications rather than every future notification type.

No webhook, desktop integration, or remote credential is required by the reference implementation.

System changes

  • Adds a local-only notification side effect with no persistence.

Syntax explained

systemMessage
Displays a fixed reviewed message without copying event content.
File .claude/hooks/notify.mjs
Configuration
const raw = await new Response(process.stdin).text();
let input;
try {
  input = JSON.parse(raw);
} catch {
  console.error("Notification hook received malformed JSON");
  process.exit(1);
}

const type = typeof input.notification_type === "string"
  ? input.notification_type
  : "unknown";
const labels = {
  permission_prompt: "Claude Code needs a permission decision.",
  idle_prompt: "Claude Code is waiting for operator input.",
  agent_completed: "A Claude Code agent completed."
};
console.log(JSON.stringify({
  systemMessage: labels[type] ?? "Claude Code emitted a reviewed notification.",
  terminalSequence: "\u0007"
}));
Example output / evidence
{"systemMessage":"Claude Code needs a permission decision.","terminalSequence":"\u0007"}

Checkpoint: Inject a synthetic sensitive notification

Continue whenThe output contains the fixed label and does not contain the supplied message, title, session, path, or prompt.

Stop whenStop if notification content is copied, logged, transmitted, or used to construct a command.

If this step fails

The user receives no local alert.

Likely causeThe notification type does not match or the terminal does not render the bell.

Safe checks
  • Inspect `/hooks` matcher details
  • Run the synthetic notification fixture

ResolutionKeep the privacy-safe fixed message and adjust only the reviewed matcher or local presentation.

Stop conditions

  • Stop if notification content is copied, logged, transmitted, or used to construct a command.
06

config

Record sanitized configuration-change evidence

caution

Create a ConfigChange hook that records only timestamp, event, documented source, basename, and a fixed decision. Store the JSONL file in an ignored private directory with restrictive permissions; never persist raw stdin.

Why this step matters

A minimal audit trail helps explain policy drift without retaining prompts, diffs, session identifiers, full personal paths, or configuration contents.

What to understand

ConfigChange observation is evidence, not an immutable boundary; changing or removing the hook itself can stop future records.

The contract harness redirects audit output to scratch space and proves that sensitive fixture fields are absent.

System changes

  • Adds an ignored local JSONL evidence file when configuration changes occur.

Syntax explained

path.basename
Prevents storing the operator's full private directory path.
File .claude/hooks/audit-config.mjs
Configuration
import fs from "node:fs";
import path from "node:path";

const raw = await new Response(process.stdin).text();
let input;
try {
  input = JSON.parse(raw);
} catch {
  console.error("Config audit received malformed JSON");
  process.exit(1);
}

const projectRoot = fs.realpathSync(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
const destination = path.resolve(
  process.env.CLAUDE_AUDIT_PATH ?? path.join(projectRoot, ".claude-audit", "config-events.jsonl")
);
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });

const record = {
  at: new Date().toISOString(),
  event: "ConfigChange",
  source: typeof input.source === "string" ? input.source : "unknown",
  file: typeof input.file_path === "string" ? path.basename(input.file_path) : "unknown",
  decision: "observed"
};
fs.appendFileSync(destination, JSON.stringify(record) + "\n", {
  encoding: "utf8",
  mode: 0o600
});
console.log(JSON.stringify({ systemMessage: "Recorded a sanitized configuration change." }));
Example output / evidence
{"at":"2026-07-29T10:30:00.000Z","event":"ConfigChange","source":"project_settings","file":"settings.json","decision":"observed"}

Checkpoint: Audit a synthetic project-settings change

Continue whenExactly one bounded JSON line is appended and it contains no session ID, raw path, prompt, content, or token.

Stop whenStop if raw hook input or configuration content reaches the audit file.

If this step fails

The audit directory cannot be written.

Likely causePermissions, disk state, or the configured destination is invalid.

Safe checks
  • Check only directory ownership and free space
  • Use the contract scratch destination

ResolutionSurface a sanitized error, preserve the config change state, and repair the audit path without broadening permissions.

Stop conditions

  • Stop if raw hook input or configuration content reaches the audit file.
07

config

Register hooks with fixed commands and root placeholders

caution

Add the four event groups to project settings. Use `command` plus `args` and `${CLAUDE_PROJECT_DIR}` substitution so hooks do not depend on the session's current directory or shell interpolation.

Why this step matters

Declarative matchers and fixed argv make hook discovery reviewable and avoid relative-path failures after Claude changes directories.

What to understand

Each event has a bounded timeout appropriate to its deterministic local work.

The Notification and ConfigChange matchers enumerate reviewed event classes instead of silently covering all future types.

System changes

  • Enables four project hook groups after their scripts and tests exist.

Syntax explained

${CLAUDE_PROJECT_DIR}
Claude Code placeholder resolved to the reviewed project root regardless of current working directory.
File .claude/settings.json
Configuration
{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": [
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-paths.mjs"
            ],
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": [
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-changed.mjs"
            ],
            "timeout": 20
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt|agent_completed",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": [
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.mjs"
            ],
            "timeout": 3
          }
        ]
      }
    ],
    "ConfigChange": [
      {
        "matcher": "project_settings|local_settings|skills",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": [
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/audit-config.mjs"
            ],
            "timeout": 5
          }
        ]
      }
    ]
  }
}
Example output / evidence
hooks=4 PreToolUse=1 PostToolUse=1 Notification=1 ConfigChange=1 commands=fixed project_root=placeholder

Checkpoint: Inspect registered hooks

Continue when`claude doctor` accepts the settings and `/hooks` shows four project groups with the expected commands, args, matchers, and timeouts.

Stop whenStop if a command uses a relative script path, a shell-expanded input, an unknown event, or an unexpected settings source.

If this step fails

Hooks work at the repository root but fail in a subdirectory.

Likely causeThe command uses a cwd-relative path instead of the project-root placeholder.

Safe checks
  • Inspect `/hooks` command and args
  • Repeat the fixture after changing cwd

ResolutionRestore the fixed command plus `${CLAUDE_PROJECT_DIR}` args form and rerun the contract.

Stop conditions

  • Stop if a command uses a relative script path, a shell-expanded input, an unknown event, or an unexpected settings source.
08

config

Keep local hook evidence out of source control

caution

Add exact ignore entries for audit and scratch directories. Hook scripts and project settings remain tracked and reviewable; their generated evidence and test fixtures do not.

Why this step matters

Sanitized evidence can still reveal activity timing and filenames, while test scratch files should never become repository history.

What to understand

The ignore entries are narrow and do not hide hook code or shared settings from review.

A production audit destination may require encrypted centralized storage, but that is a separate authenticated design rather than an automatic hook upload.

System changes

  • Updates `.gitignore` for generated hook evidence and fixtures only.

Syntax explained

.claude-audit/
Local evidence directory created with restrictive permissions by the audit hook.
File .gitignore
Configuration
# Claude Code hook-local state
.claude-audit/
.tmp/hooks/
Example output / evidence
.claude-audit ignored
.tmp/hooks ignored
.claude/hooks tracked
.claude/settings.json tracked

Checkpoint: Inspect Git visibility

Continue whenGenerated evidence is ignored and every executable hook plus settings file remains visible in `git status`.

Stop whenStop if `.claude/` or another broad parent directory is ignored.

If this step fails

Hook scripts are missing from the review diff.

Likely causeAn older broad ignore rule hides `.claude`.

Safe checks
  • git check-ignore -v .claude/hooks/protect-paths.mjs

ResolutionReplace the broad ignore with the two exact generated-state entries.

Stop conditions

  • Stop if `.claude/` or another broad parent directory is ignored.
09

config

Create the complete hook contract harness

caution

Add a deterministic harness that invokes every script as a separate Node process with synthetic JSON, verifies blocking and allow cases, proves malformed input fails closed, checks actual formatting, confirms notification redaction, and inspects the sanitized audit record.

Why this step matters

A runnable harness verifies the concrete files shown in the tutorial and prevents illustrative output from being mistaken for evidence.

What to understand

The test creates and removes only `.tmp/hooks`, never a real source, workflow, secret, or user audit file.

Assertions deliberately prove that supplied sensitive notification text, session IDs, and full paths do not survive.

System changes

  • Adds one self-cleaning integration-style hook test.

Syntax explained

shell: false
Ensures hook scripts and their tests are invoked as executable plus argv without shell parsing.
File test/hooks-contract.mjs
Configuration
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";

const root = fs.realpathSync(process.cwd());
const hookDir = path.join(root, ".claude", "hooks");
const scratch = path.join(root, ".tmp", "hooks");
const auditPath = path.join(scratch, "config-events.jsonl");
fs.rmSync(scratch, { recursive: true, force: true });
fs.mkdirSync(scratch, { recursive: true });

function run(script, input, env = {}) {
  return spawnSync(process.execPath, [path.join(hookDir, script)], {
    cwd: root,
    input: JSON.stringify(input),
    encoding: "utf8",
    timeout: 20_000,
    shell: false,
    env: {
      ...process.env,
      CLAUDE_PROJECT_DIR: root,
      CLAUDE_AUDIT_PATH: auditPath,
      ...env
    }
  });
}

try {
  const blocked = run("protect-paths.mjs", {
    tool_name: "Edit",
    tool_input: { file_path: ".github/workflows/release.yml" }
  });
  assert.equal(blocked.status, 2);
  assert.match(blocked.stderr, /protected path/i);

  const allowed = run("protect-paths.mjs", {
    tool_name: "Edit",
    tool_input: { file_path: "src/example.ts" }
  });
  assert.equal(allowed.status, 0, allowed.stderr);

  const malformed = spawnSync(
    process.execPath,
    [path.join(hookDir, "protect-paths.mjs")],
    {
      cwd: root,
      input: "{not-json",
      encoding: "utf8",
      env: { ...process.env, CLAUDE_PROJECT_DIR: root }
    }
  );
  assert.equal(malformed.status, 2);

  const formatTarget = path.join(scratch, "example.ts");
  fs.writeFileSync(formatTarget, "export const value={answer:42}\n");
  const formatted = run("format-changed.mjs", {
    tool_name: "Write",
    tool_input: { file_path: path.relative(root, formatTarget) }
  });
  assert.equal(formatted.status, 0, formatted.stderr);
  assert.match(fs.readFileSync(formatTarget, "utf8"), /answer: 42/);

  const notification = run("notify.mjs", {
    hook_event_name: "Notification",
    notification_type: "permission_prompt",
    message: "sensitive text that must not be copied"
  });
  assert.equal(notification.status, 0, notification.stderr);
  assert.doesNotMatch(notification.stdout, /sensitive text/);
  assert.match(notification.stdout, /permission decision/);

  const audit = run("audit-config.mjs", {
    hook_event_name: "ConfigChange",
    source: "project_settings",
    file_path: "/private/operator/repository/.claude/settings.json",
    session_id: "must-not-be-stored"
  });
  assert.equal(audit.status, 0, audit.stderr);
  const record = fs.readFileSync(auditPath, "utf8");
  assert.match(record, /"source":"project_settings"/);
  assert.doesNotMatch(record, /private\/operator|session_id|must-not-be-stored/);

  console.log(
    "HOOK_CONTRACT_OK protected=denied allowed=passed malformed=denied " +
    "formatted=passed notification=redacted audit=sanitized"
  );
} finally {
  fs.rmSync(scratch, { recursive: true, force: true });
}
Example output / evidence
HOOK_CONTRACT_OK protected=denied allowed=passed malformed=denied formatted=passed notification=redacted audit=sanitized

Checkpoint: Run syntax and contract tests

Continue when`npm run check:hooks` passes and `npm run test:hooks` prints the exact `HOOK_CONTRACT_OK` line.

Stop whenStop on any failed assertion, leftover scratch file, content leak, unbounded process, or differing formatter result.

If this step fails

The harness passes locally but a live hook differs.

Likely causeThe active settings point to another script or settings source.

Safe checks
  • Inspect `/hooks` source and command
  • Compare file checksums

ResolutionDisable live hooks, correct the active source, and rerun both harness and interactive fixtures.

Stop conditions

  • Stop on any failed assertion, leftover scratch file, content leak, unbounded process, or differing formatter result.
10

verification

Exercise live hook timing and recovery

caution

In a disposable session, inspect `/hooks`, attempt a protected workflow edit, write an unformatted scratch fixture, trigger a permission notification, and externally touch project settings. Confirm the live result matches the harness and that PostToolUse feedback never claims to undo an edit.

Why this step matters

Live validation proves event discovery, matcher selection, placeholder substitution, exit semantics, and file watching that direct script invocation cannot exercise.

What to understand

Use only synthetic inputs and the ignored scratch path; do not trigger tests with a production workflow or real secret.

Change cwd before one fixture to prove `${CLAUDE_PROJECT_DIR}` keeps script resolution stable.

System changes

  • Creates and removes only ignored scratch and sanitized audit fixtures.

Syntax explained

/hooks
Read-only view of active handlers, matchers, source scopes, commands, and types.
Command
npm run check:hooks && npm run test:hooks && claude doctor
Example output / evidence
syntax=passed contract=passed doctor=passed protected_edit=blocked scratch=formatted notification=local audit=sanitized

Checkpoint: Approve live event behavior

npm run check:hooks && npm run test:hooks && claude doctor

Continue whenEvery event fires once with the documented timing and no raw input enters output, audit, Git, or another service.

Stop whenStop if a protected edit proceeds, a hook runs twice unexpectedly, formatting touches another file, or notification/audit leaks content.

If this step fails

A malformed or timed-out PreToolUse hook allows the edit.

Likely causeThe active version or hook type does not use the expected fail-closed behavior.

Safe checks
  • Record the active Claude version and hook source
  • Inspect sanitized debug status

ResolutionSet `disableAllHooks` externally, block repository use, and repair the gate before any real edit.

Stop conditions

  • Stop if a protected edit proceeds, a hook runs twice unexpectedly, formatting touches another file, or notification/audit leaks content.
11

verification

Canary the hook suite with an emergency disable ready

read-only

Review the exact scripts and lockfile, canary with one repository and contributor, monitor execution count, duration and sanitized failure class, and keep an external settings edit ready to set `disableAllHooks` if false blocks or crashes affect work.

Why this step matters

Hooks execute automatically with user privileges, so a small canary limits the effect of platform, shell, dependency, and repository-layout differences.

What to understand

Metrics contain event class, duration, exit class, and policy revision only; they never contain stdin, prompts, tool output, diffs, or file contents.

Managed hooks cannot be disabled by project or user settings, so their incident path must be owned by the administrator.

System changes

  • Approves one immutable hook revision for a bounded audience without automatically committing or pushing.

Syntax explained

false_blocks=0
Release gate requiring no unexplained denial of an allowed synthetic fixture.
Command
git diff --check && git status --short && npm run check:hooks && npm run test:hooks
Example output / evidence
diff=clean scripts=4 tests=passed canary=approved false_blocks=0 content_logs=0 rollback=ready

Checkpoint: Authorize wider enablement

git diff --check && git status --short && npm run check:hooks && npm run test:hooks

Continue whenThe canary matches the harness, produces no privacy leak, and the owner demonstrates the appropriate disable path.

Stop whenStop on a protected-write false negative, content leak, repeated crash, unexplained duplicate execution, or unavailable disable owner.

If this step fails

The canary must be disabled.

Likely causeA platform or repository difference violates the tested assumptions.

Safe checks
  • Set `disableAllHooks` in an editable external scope
  • Preserve only sanitized revision and failure labels

ResolutionRestore the previous settings and scripts, clean scratch state, and rerun all fixtures before re-enabling.

Stop conditions

  • Stop on a protected-write false negative, content leak, repeated crash, unexplained duplicate execution, or unavailable disable owner.

Finish line

Verification checklist

Hook syntaxnpm run check:hooksNode parses all four hook scripts without executing them or reading hook input.
Deterministic hook contractnpm run test:hooksPrints the exact `HOOK_CONTRACT_OK` record after blocking, formatting, redaction, and audit assertions pass.
Active hook discoveryclaude doctor, then inspect `/hooks` in the disposable repository session.Four project hook groups show the reviewed matchers, fixed command plus args, project-root placeholders, and bounded timeouts.

Recovery guidance

Common problems and safe checks

A hook does not fire.

Likely causeThe event or matcher differs, settings did not load, or the active hook comes from another scope.

Safe checks
  • Run `claude doctor`
  • Inspect `/hooks` source, matcher, command and event

ResolutionCorrect the active settings and repeat the exact synthetic fixture before using real work.

A hook script is not found after changing directories.

Likely causeIts command uses a relative path instead of `${CLAUDE_PROJECT_DIR}` or `${CLAUDE_PLUGIN_ROOT}`.

Safe checks
  • Inspect `/hooks` args
  • Repeat the fixture from a nested cwd

ResolutionUse a fixed command with the documented root placeholder in args.

A protected edit is allowed.

Likely causeThe PreToolUse handler crashed non-blockingly, used the wrong exit, or canonicalization missed the target.

Safe checks
  • Run blocked, malformed and symlink fixtures
  • Record sanitized exit and normalized path only

ResolutionDisable repository use, restore the reviewed exit-2 gate, and add a regression fixture.

Formatting changes additional files.

Likely causeThe formatter received a directory, glob, config side effect, or shell-expanded target.

Safe checks
  • Inspect argv and changed-file list
  • Run the scratch fixture with `shell: false`

ResolutionRestrict the runner to one canonical allowlisted file after `--` and restore unrelated files.

The audit file contains sensitive data.

Likely causeRaw hook input or full paths were persisted for convenience.

Safe checks
  • Stop collection
  • Identify affected local records without redisplaying content

ResolutionDelete under the incident policy, rotate exposed credentials when applicable, and store only the fixed minimal schema.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Use native formatter integration in the editor or CI when automatic post-edit mutation is not worth the local code-execution risk.
  • Use permission deny rules or managed policy instead of hooks for simple stable path and tool restrictions.
  • Use a centralized authenticated audit pipeline only after designing redaction, encryption, backpressure, retry, retention, deletion, and incident behavior separately.

Operate it safely

  • Add repository-specific positive and negative fixtures for every protected directory and supported formatter language.
  • Measure p50 and p95 hook duration and investigate regressions without adding raw content to telemetry.
  • Move mandatory organization-wide hooks into managed policy only after administrators own deployment and emergency disable.
  • Review new Claude Code events and exit semantics before adding them to matchers.

Reference

Frequently asked questions

Can a PostToolUse hook block the edit that triggered it?

No. The tool already ran. Use PreToolUse or explicit permission rules for prevention; PostToolUse is appropriate for formatting and feedback.

Why use command plus args instead of one shell string?

It keeps script and target boundaries explicit, avoids quoting mistakes, and prevents hook input from becoming executable shell syntax.

Are project hooks sandboxed like Claude's Bash commands?

Do not assume so. Official guidance warns that command hooks run with the user's full permissions. Review them as executable code.

How do I recover from a hook that blocks everything?

Close the session and use an external editor to set `disableAllHooks: true` or restore the previous settings. Managed hooks require the administrator's managed switch.

Should notifications be forwarded to Slack or email?

Only through a separately reviewed integration with consent, redaction, authentication, rate limits, retry, retention and deletion. The reference stays local and ignores free-form event text.

Recovery

Rollback

Use an external editor to set `disableAllHooks: true` or restore the prior settings before reopening Claude Code, then restore scripts and lockfile, clean only generated scratch/audit state, and repeat syntax, contract, discovery, and live negative tests. Project or user switches cannot disable hooks forced by managed settings.

  1. Close affected Claude Code sessions and stop new sessions in the repository.
  2. From an external editor, set `disableAllHooks: true` in an editable settings scope or restore the last known-good `.claude/settings.json`; contact the administrator for managed hooks.
  3. Preserve only hook revision, event class, exit class, duration, and affected normalized path; never preserve raw stdin, prompt, transcript, diff, or secret.
  4. Restore `.claude/hooks`, `.claude/settings.json`, `package.json`, and the lockfile from the reviewed revision.
  5. Remove `.tmp/hooks` and handle `.claude-audit` according to retention; rotate credentials if a hook transmitted or printed protected content.
  6. Run syntax and contract tests with hooks disabled, reopen a disposable session, inspect `/hooks`, and repeat all live fixtures before clearing the disable switch.

Evidence

Sources and review

Verified 2026-07-29Review due 2026-10-27
Automate actions with Claude Code hooks | AnthropicofficialClaude Code hooks reference | AnthropicofficialClaude Code settings | AnthropicofficialClaude Code security | Anthropicofficial