Blog • Claude Code / Agent SDK
After upgrading to Claude Code 2.1.317 with Agent SDK hooks onFailure set to block, how should JSON logs record blocks when a hook callback fails?
On November 3, 2026 Anthropic shipped Claude Code 2.1.317. Agent SDK hook callbacks gain onFailure value block: when a PreToolUse or similar hook script exits non-zero or times out, the client no longer fail-opens into the tool but blocks the action and logs blocked in audit JSON.
Hook callback result JSON should record hookName, onFailure block, exitCode, and timedOut; post-block audit JSON should carry decision blocked, reason hook_callback_failed, plus hookName and exitCode. Operators can mirror hook_failure_gate branches schema, not_block_mode, allow, block, and timeout_block instead of treating callback failure as permission policy deny.
Allow and deny queue patterns are in Agent permission policy; Managed Agents auto evaluation context is in Managed Agents auto evaluation; session workflow JSON is in Claude Code workflow. This article covers only 2.1.317 onFailure block, hooks settings and callback result JSON, a Bash call sample, hook_callback_failed audit fields, and how hook_failure_gate branches.
- PreToolUse and similar entries in hooks settings must set onFailure to block to stop the tool on callback failure; when unset or not block, hook_failure_gate should return not_block_mode instead of logging reason hook_callback_failed.
- Hook callback result JSON must include hookName, onFailure, integer exitCode, and boolean timedOut; exitCode zero with timedOut false yields allow, non-zero exitCode with onFailure block yields block.
- Audit logs on the block branch should record decision blocked, reason hook_callback_failed, and keep hookName plus exitCode; SIEM should aggregate by hookName instead of mixing permission policy deny reasons.
- When timedOut is true and onFailure is block, hook_failure_gate returns timeout_block; if settings or callback result JSON has a bad shape, return schema before block logic.
Keep this in mind: 2.1.317 changes block semantics when hook callbacks fail and hook_callback_failed audit fields; it does not rewrite allow or deny tables in a permission policy. Below: changelog, hooks settings JSON, callback result JSON, Bash call sample, audit logs and table, hook_failure_gate source, and validating shapes in JSONNote.
How does 2.1.317 block instead of fail-open when a hook callback fails?
Release notes are in the Claude Code changelog, tag page at v2.1.317 release. Versus earlier Agent SDK hook behavior, the visible change is onFailure block: when PreToolUse or similar hook scripts exit non-zero or time out, the tool action is blocked instead of logging a warning and proceeding.
The block runs after the hook callback finishes and before tool execution. Audit JSON should keep decision blocked, reason hook_callback_failed, hookName, and exitCode so SIEM attributes to the failing hook instead of a generic tool denied line.
How do you set PreToolUse and onFailure block in hooks settings JSON?
Under hooks in user or project settings, configure PreToolUse with matcher and command and set onFailure to block on the same entry. From 2.1.317 only explicit block stops the tool on callback failure; default or other values keep fail-open or warn paths and hook_failure_gate should return not_block_mode.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "./hooks/pre-bash.sh",
"onFailure": "block"
}
]
}
]
}
}
If operators store onFailure with wrong casing or omit hooks, local schema validation should fail with schema before hook_failure_gate block branches. Do not treat shape errors as not_block_mode.
How do hookName and exitCode appear in hook callback result JSON?
After the hook callback finishes, the client writes result JSON: hookName names the PreToolUse entry, onFailure echoes block from settings, exitCode is the script exit code, timedOut marks a timeout kill. A Bash call still allowed by permission may still be blocked when the hook fails.
{
"hookName": "pre-bash.sh",
"onFailure": "block",
"exitCode": 2,
"timedOut": false
}
exitCode must be an integer and timedOut a boolean; missing hookName or bad onFailure shape makes hook_failure_gate return schema first. When onFailure is not block, return not_block_mode to avoid hook_callback_failed log templates.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["hookName", "onFailure", "exitCode", "timedOut"],
"properties": {
"hookName": { "type": "string", "minLength": 1 },
"onFailure": { "type": "string", "enum": ["block", "continue"] },
"exitCode": { "type": "integer" },
"timedOut": { "type": "boolean" }
}
}
How should Bash tool call JSON look before and after a hook block?
Sample: permission policy already allows Bash; call JSON has tool Bash and command npm test. When the PreToolUse hook exits with code 2 and onFailure block, the client skips Bash and audit JSON records decision blocked and reason hook_callback_failed. Keep the original command and hookName for hook_failure_gate block branch checks.
{
"tool": "Bash",
"command": "npm test"
}
{
"hookName": "pre-bash.sh",
"onFailure": "continue",
"exitCode": 2,
"timedOut": false
}
How should decision blocked and hook_callback_failed appear in audit logs?
When a hook callback fails with onFailure block, tool audit JSON should set decision blocked, reason hook_callback_failed, and keep hookName plus non-zero exitCode; when timedOut is true you may add a timeout flag but reason stays hook_callback_failed. Do not mix permission deny and hook block in one reason enum.
{
"tool": "Bash",
"decision": "blocked",
"reason": "hook_callback_failed",
"hookName": "pre-bash.sh",
"onFailure": "block",
"exitCode": 2
}
| Log or call signal | hook_failure_gate returns | Operator action |
|---|---|---|
| exitCode 0, timedOut false, onFailure block | allow | Tool may run; no hook_callback_failed in the log. |
| Non-zero exitCode, onFailure block, timedOut false | block | Align with decision blocked, reason hook_callback_failed, and exitCode. |
| onFailure unset block or value warn | not_block_mode | Do not write hook_callback_failed; compare to legacy fail-open reports. |
| timedOut true and onFailure block | timeout_block | Logs still use hook_callback_failed; SIEM counts timeout_block separately. |
How does hook_failure_gate choose among schema and five business branches?
Order: validate hooks settings and callback result JSON Schema for onFailure plus hookName, exitCode, timedOut; onFailure not block → not_block_mode; timedOut true with block → timeout_block; exitCode zero → allow; non-zero exitCode with block → block; bad shape → schema.
Do not collapse block and timeout_block into one hook_failed bit. Count separately in reports to distinguish not_block_mode fail-open, exitCode failure block, and true timeout_block.
def actual_type(data: object) -> str:
if data is None:
return "null"
if isinstance(data, bool):
return "boolean"
if isinstance(data, list):
return "array"
if isinstance(data, int):
return "integer"
if isinstance(data, float):
return "number"
if isinstance(data, str):
return "string"
if isinstance(data, dict):
return "object"
raise TypeError(type(data))
def validate(data: object, schema: dict) -> list[str]:
errors: list[str] = []
if not isinstance(schema, dict):
return errors
types = schema.get("type")
if isinstance(types, str):
types = [types]
actual = actual_type(data)
if types and not any(t == actual or (t == "number" and actual == "integer") for t in types):
errors.append("type")
if "enum" in schema and not any(value == data for value in schema["enum"]):
errors.append("enum")
if isinstance(data, str) and "minLength" in schema and len(data) < schema["minLength"]:
errors.append("minLength")
if isinstance(data, dict) and schema.get("properties"):
for key in schema.get("required") or []:
if key not in data:
errors.append(f"required:{key}")
if schema.get("additionalProperties") is False:
for key in data:
if key not in schema["properties"]:
errors.append(f"additional:{key}")
for key, sub in schema["properties"].items():
if key in data and data[key] is not None:
errors.extend(validate(data[key], sub))
return errors
def hook_failure_gate(ctx: dict, schema: dict) -> str:
if validate(ctx, schema):
return "schema"
mode = ctx.get("onFailure")
if mode != "block":
return "not_block_mode"
if ctx.get("timedOut"):
return "timeout_block"
code = ctx.get("exitCode")
if code == 0:
return "allow"
return "block"
How do you validate hooks settings, callback results, and audit logs in JSONNote?
- 1Pin hooks shape with Schema
Open JSON Schema. Paste hooks settings schema in the first field and a PreToolUse plus onFailure block sample in the second. required or enum errors should flag first; hook_failure_gate must not return allow or block on a bad shape.
- 2Use Format to inspect tool calls
Open JSON Format, paste Bash tool call JSON. Confirm tool and command keys exist before checking whether hook_failure_gate should return block or allow.
- 3Diff allow versus blocked audit logs
Open JSON Diff. Put a hook success allow sample on the left and a decision blocked plus reason hook_callback_failed sample on the right. The highlighted hookName is the line your SIEM rule should key on.
Common questions
How is onFailure block different from old fail-open?
Legacy hook failures usually logged stderr or a warning then continued the tool. With explicit onFailure block in settings from 2.1.317, non-zero exitCode or timeout writes decision blocked and reason hook_callback_failed and skips the tool. Without block, behavior stays fail-open and hook_failure_gate should return not_block_mode.
How do logs distinguish exit code 2 from other non-zero codes?
From 2.1.317 block semantics depend on whether exitCode is zero, not only on 2. Audit JSON should store the integer exitCode as-is; SIEM may count 2 separately from 1 or 127, but hook_failure_gate block treats every non-zero exitCode the same. Do not collapse exitCode to a boolean failed.
What onFailure values exist besides block?
Common values include warn or default fail-open: callback failure warns but still runs the tool. Only block triggers decision blocked. Use not_block_mode in reports to count fail-open; do not log warn as hook_callback_failed.
How does hook block relate to the permission policy layer?
Permission policy decides default allow deny or ask per tool type; hooks add a script check before execution. Even when policy allows Bash, a failed PreToolUse with onFailure block still stops the call. Keep both JSON sources separate; hook_failure_gate mirrors the hook layer only, it does not decide policy allow.
Does this article provide attack or bypass tutorials?
No. This article only explains 2.1.317 onFailure block JSON shapes, hook_callback_failed audit fields, and hook_failure_gate branches for operator and compliance validation and SIEM attribution. It does not provide steps to exploit hook failures or exit codes to bypass controls.
Summary
Claude Code 2.1.317 supports onFailure block on Agent SDK hooks and writes decision blocked, reason hook_callback_failed, hookName, and exitCode in audit JSON when callbacks fail. Drive hook_failure_gate with hooks settings, callback results, Bash calls, and blocked audit samples instead of watching permission allow alone.
Decision: settings or callback JSON failing schema → schema; onFailure not block → not_block_mode; exitCode zero → allow; non-zero exitCode with block → block; timedOut true with block → timeout_block; block and timeout_block audits align with decision blocked, reason hook_callback_failed, hookName, and exitCode.
Next: pin hooks settings and callback results with Schema, use Format to inspect Bash calls, Diff allow versus blocked audit logs, and report hook_failure_gate return values separately.