jared hebb ~ %

Claude Code hooks

The ConfigChange hook in Claude Code

ConfigChange fires when settings change mid-session and can block the change. Matches on which settings file it came from.

skills is the source value easiest to miss in the matcher list, because it means a skill file changed, not settings.json itself. A hook that only matches project_settings or local_settings never sees a new or edited skill land mid-session.

When it fires
When a configuration file changes during a session.
Can it block?
Yes, with one exception: policy settings cannot be blocked.
Matcher
Yes, on source
Matcher examples
user_settings project_settings local_settings policy_settings skills

What it receives

The event arrives as JSON on standard input. These are the fields worth reading.

FieldTypeWhat it is
sourcestringWhich settings file changed. Also what the matcher filters on.
changed_settingsobjectThe fields that changed.
session_idstringStable for the session.
cwdstringWorking directory.

Exit codes

The exit code is the decision. Anything the script writes to standard error on a blocking exit is what Claude gets told.

ExitWhat happens
0Allow the change.
2Block the change, except when the source is policy_settings. stderr is shown.
otherNon-blocking error.

What it can return

Blocking here stops the new configuration taking effect for the rest of the session. The obvious use is refusing changes a session made to its own permissions.

{
  "decision": "block",
  "reason": "string"
}

Not letting a session widen its own permissions

A session that can edit settings.json can grant itself anything. Watching project settings for permission changes and refusing them keeps that decision with you.

In settings.json

{
  "hooks": {
    "ConfigChange": [
      {
        "matcher": "project_settings|local_settings",
        "hooks": [
          { "type": "command", "command": "node ~/.claude/hooks/guard-permissions.mjs" }
        ]
      }
    ]
  }
}

The script

const input = JSON.parse(require('fs').readFileSync(0, 'utf8')) || {};
if (!('permissions' in (input.changed_settings || {}))) process.exit(0);

console.error('Permissions changed mid-session. Ask before widening them.');
process.exit(2);

policy_settings is deliberately exempt from blocking, so an organization-wide policy cannot be refused by a local hook.

The catch

This fires on changes made during a session, not on what was loaded at the start. Settings already in place when the session began never raise it.