Skip to content

security-guidance: _call_claude sends deprecated top-level output_format → 400 on every hook fire (guardrail fails open) #2098

Description

@kkroo

Summary

plugins/security-guidance/hooks/llm.py_call_claude builds the Messages API request body with a top-level output_format field. The API now rejects it:

HTTP 400 invalid_request_error
output_format: This field is deprecated. Use 'output_config.format' instead.
See https://platform.claude.com/docs/en/build-with-claude/structured-outputs

Since this is the structured-output request the LLM-backed hooks rely on, every hook fire 400s_call_claude returns None → the security guardrail silently fails open, while still consuming one API call per fire. The structured-outputs-2025-11-13 beta header is already sent in _build_auth_headers; only the request-body field name changed upstream.

Repro

Replay the exact request _call_claude builds (beta header structured-outputs-2025-11-13, model e.g. claude-opus-4-7, top-level output_format) against /v1/messages → the 400 above. Swapping the field to output_config.format makes it pass validation.

Affected versions

Reproduced on security-guidance@2.0.0 and present on main HEAD (plugins/security-guidance/hooks/llm.py:482).

Fix

Nest the schema under output_config.format. Gotcha: the adaptive-thinking branch already does payload["output_config"] = {"effort": "high"}, which would clobber the schema — so effort must be merged into the existing dict, not reassigned:

-        "output_format": {
-            "type": "json_schema",
-            "schema": output_schema
-        }
+        "output_config": {
+            "format": {
+                "type": "json_schema",
+                "schema": output_schema,
+            }
+        },
 ...
-            payload["output_config"] = {"effort": "high"}
+            payload["output_config"]["effort"] = "high"

The two claude_agent_sdk ClaudeAgentOptions(output_format=...) call sites are a separate SDK contract and should be left unchanged.

I opened #2097 with this exact patch before realizing the repo only accepts internal PRs (auto-closed) — the diff there is ready to cherry-pick.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions