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_settingsproject_settingslocal_settingspolicy_settingsskills
What it receives
The event arrives as JSON on standard input. These are the fields worth reading.
| Field | Type | What it is |
|---|---|---|
source | string | Which settings file changed. Also what the matcher filters on. |
changed_settings | object | The fields that changed. |
session_id | string | Stable for the session. |
cwd | string | Working 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.
| Exit | What happens |
|---|---|
0 | Allow the change. |
2 | Block the change, except when the source is policy_settings. stderr is shown. |
other | Non-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.