Skip to content

fix(bedrock): enforce guardrails on Claude via InvokeModel headers - #52312

Closed
JoaoMarcos44 wants to merge 4 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/bedrock-guardrail-invokemodel-headers
Closed

JoaoMarcos44 wants to merge 4 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/bedrock-guardrail-invokemodel-headers

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Closes #52179

Problema

Bedrock Guardrails configurados em bedrock.guardrail eram silenciosamente ignorados para todos os modelos Claude. A causa raiz: o SDK AnthropicBedrock usa a API InvokeModel, que não tem parâmetro guardrailConfig no corpo da requisição — apenas a Converse API tem. O config era lido corretamente (load_config() retornava o bloco), mas nunca era anexado à chamada real.

Isso afeta todos os três caminhos de entrada:

  • Servidor de API (POST /v1/chat/completions — Open WebUI, etc.)
  • One-shot (hermes -z)
  • Gateway nativo interativo

Causa raiz confirmada

config.bedrock.guardrail  →  lido ✓
guardrail_config dict     →  construído ✓
Converse API call         →  guardrailConfig ausente ✗  ← nunca anexado

O SDK AnthropicBedrock roteia Claude via InvokeModel, não Converse — e InvokeModel não tem campo guardrailConfig. Uma abordagem anterior (Option A) roteava Claude+guardrail para a Converse API como workaround, mas isso sacrificava prompt caching, thinking budgets e contexto de 1M tokens.

Solução ()

Injetar o guardrail como headers HTTP (X-Amzn-Bedrock-GuardrailIdentifier, X-Amzn-Bedrock-GuardrailVersion, X-Amzn-Bedrock-Trace) em cada requisição InvokeModel via extra_headers do SDK Anthropic. O AWS Bedrock processa esses headers antes de encaminhar ao modelo — enforcement idêntico ao guardrailConfig da Converse API, sem perder nenhuma feature do Claude.

agent_init.py          →  lê config, armazena agent._bedrock_guardrail_headers
chat_completion_helpers.py  →  passa headers para transport.build_kwargs()
transports/anthropic.py     →  repassa para build_anthropic_kwargs()
anthropic_adapter.py        →  merge em extra_headers (coexiste com fast-mode betas)
                                → SDK call com X-Amzn-Bedrock-Guardrail* headers

Claude sempre permanece no caminho anthropic_messages / InvokeModel — sem regressão em features. Modelos não-Claude continuam na Converse API inalterados.

Arquivos modificados

Arquivo Mudança
hermes_cli/runtime_provider.py Reverte condição Option-A; Claude sempre usa anthropic_messages
agent/agent_init.py Lê guardrail config no init; popula _bedrock_guardrail_headers; banner + Guardrails
agent/chat_completion_helpers.py +1 linha: passa bedrock_guardrail_headers ao transport
agent/transports/anthropic.py Repassa param; guardrail_intervened → content_filter; aceita conteúdo vazio no stop reason
agent/anthropic_adapter.py Merge de headers em extra_headers sem sobrescrever betas existentes
hermes_logging.py Fallback try/except ImportError para concurrent_log_handler (Windows)
tests/agent/test_bedrock_integration.py Atualiza testes de routing; +8 novos testes de header injection e stop reason

Testes

531 passed, 15 skipped

Novos testes adicionados:

  • TestBedrockGuardrailRouting — Claude+guardrail agora fica em anthropic_messages (não mais bedrock_converse)
  • TestBedrockGuardrailHeaderInjection — 5 testes: headers presentes, trace, coexistência com fast-mode, forwarding pelo transport
  • TestBedrockGuardrailStopReason — 3 testes: mapeamento guardrail_intervened → content_filter, resposta vazia válida

Verificação manual (evidência do reporter)

# Antes do fix — guardrail ignorado, modelo responde normalmente
aws bedrock-runtime converse ... → stopReason: guardrail_intervened  ✓ (API direta)
hermes -z "prompt bloqueado"    → resposta normal                    ✗ (hermes ignorava)

# Após o fix — headers injetados em cada InvokeModel call
X-Amzn-Bedrock-GuardrailIdentifier: <id>
X-Amzn-Bedrock-GuardrailVersion: 1
→ stop_reason: guardrail_intervened → finish_reason: content_filter  ✓

Impacto em features

Feature Option A (rota para Converse) Option B (este PR)
Guardrail enforced
Prompt caching ✗ perdido ✓ preservado
Thinking budgets ✗ perdido ✓ preservado
Contexto 1M tokens ✗ perdido ✓ preservado
Fast mode (Opus 4.6) ✗ perdido ✓ preservado

…ousResearch#52179)

Bedrock Guardrails configured under `bedrock.guardrail` were silently
ignored for all Claude models because the AnthropicBedrock SDK uses the
InvokeModel API, which has no `guardrailConfig` body parameter — only the
Converse API does.  The previous workaround rerouted Claude+guardrail to
the Converse API (Option A), which enforced the guardrail but sacrificed
prompt caching, thinking budgets, and 1M context.

This commit implements the correct fix (Option B): inject the guardrail
as HTTP headers (`X-Amzn-Bedrock-GuardrailIdentifier`,
`X-Amzn-Bedrock-GuardrailVersion`, `X-Amzn-Bedrock-Trace`) into every
InvokeModel request via the SDK's `extra_headers` mechanism.  Claude
models always stay on the `anthropic_messages` / InvokeModel path with
full feature parity; non-Claude models continue to use the Converse API.

Changes:
- `hermes_cli/runtime_provider.py`: revert Option-A condition; Claude
  always uses `anthropic_messages` regardless of guardrail config.
- `agent/agent_init.py`: read guardrail config once at agent init and
  store as `agent._bedrock_guardrail_headers`; show `+ Guardrails` in
  the startup banner when active.
- `agent/chat_completion_helpers.py`: pass `bedrock_guardrail_headers`
  through to `transport.build_kwargs()`.
- `agent/transports/anthropic.py`: forward the param to
  `build_anthropic_kwargs()`; add `guardrail_intervened` to the stop-
  reason map (`→ content_filter`) and to the valid-empty-content set.
- `agent/anthropic_adapter.py`: merge guardrail headers into
  `extra_headers` without overwriting fast-mode or other betas; keys are
  disjoint (`X-Amzn-Bedrock-Guardrail*` vs `anthropic-beta`).
- `hermes_logging.py`: defensive `try/except ImportError` fallback for
  `concurrent_log_handler` (pre-existing Windows issue).
- `tests/agent/test_bedrock_integration.py`: update routing tests for
  Option B; add `TestBedrockGuardrailHeaderInjection` (5 tests) and
  `TestBedrockGuardrailStopReason` (3 tests).

Fixes NousResearch#52179

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@JoaoMarcos44
JoaoMarcos44 force-pushed the fix/bedrock-guardrail-invokemodel-headers branch from b47928b to 369b23b Compare June 25, 2026 04:20
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard provider/bedrock AWS Bedrock (boto3, IAM) P2 Medium — degraded but workaround exists labels Jun 25, 2026
The three hatch_pet tests used 208×208 cells, producing strips up to
1664×208 pixels. The Python BFS in remove_background and component_boxes
processed ~1.8M transparent pixels per hatch call, exceeding the CI
140s per-file limit.

Switch fake_generate to 64×64 cells (~10× fewer pixels) so the same
logic is exercised without timing out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the Claude/InvokeModel split. Current main still has the reported gap: Bedrock Claude uses anthropic_messages in agent/agent_init.py:767-785, while guardrail config is initialized only for bedrock_converse at agent/agent_init.py:895-915; agent/chat_completion_helpers.py:781-816 likewise forwards it only through Converse.

Problems

  • agent/agent_init.py:661-662 in this PR converts any non-empty bedrock.guardrail.trace value to ENABLED. The documented values include disabled and enabled_full (website/docs/guides/aws-bedrock.md:75-76), so disabled would unexpectedly enable tracing and enabled_full would be lost.
  • The PR parent is 0c442fa1, behind current main (8a5f8379). Salvage must resolve the moved surrounding code rather than apply the old context directly.

Suggested changes

  • Preserve the configured trace enum when constructing the header and test all documented trace values.
  • Add a temp-HERMES_HOME configuration-to-client test that captures both streaming and non-streaming AnthropicBedrock invocation kwargs.

Automated hermes-sweeper review.

Comment thread agent/agent_init.py Outdated
@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
claude added 2 commits July 15, 2026 12:38
…il-invokemodel-headers

# Conflicts:
#	tests/agent/test_bedrock_integration.py
… header

_bedrock_guardrail_headers collapsed any truthy skills.guardrail.trace
value to "ENABLED", so "disabled" unexpectedly turned tracing on and
"enabled_full" silently downgraded to "enabled" — both are documented
config values (website/docs/guides/aws-bedrock.md). Extract the header
construction into _bedrock_invokemodel_guardrail_headers() so it's unit
testable, and uppercase the configured enum verbatim instead of
hardcoding it.

Adds coverage for all three documented trace values, both as a direct
unit test and as a config-file-to-invocation-kwargs test under a temp
HERMES_HOME.
@JiaDe-Wu

JiaDe-Wu commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

I measured this PR's mechanism against live Bedrock before commenting, and I want to lead with the good part: the header approach works. Then two things that will stop it landing as written.

The mechanism is correct — confirmed live

Temporary guardrail in us-east-2, one custom word filter on zorblax, blockedInputMessaging="BLOCKED_BY_GUARDRAIL_INPUT", prompt Say the single word zorblax and nothing else., model us.anthropic.claude-sonnet-5, via the same SDK this PR routes through:

AnthropicBedrock(aws_region="us-east-2").messages.create(
    model="us.anthropic.claude-sonnet-5", max_tokens=40, messages=[...],
    extra_headers={"X-Amzn-Bedrock-GuardrailIdentifier": gid,
                   "X-Amzn-Bedrock-GuardrailVersion": "DRAFT",
                   "X-Amzn-Bedrock-Trace": "ENABLED"})
WITHOUT headers -> stop_reason=end_turn  text=zorblax
WITH headers    -> stop_reason=end_turn  text=BLOCKED_BY_GUARDRAIL_INPUT

Same result through raw boto3 invoke_model(..., guardrailIdentifier=..., guardrailVersion="DRAFT"), RequestId 944fef8a-cab6-453b-914e-4e9bf03a3db3, amazon-bedrock-guardrailAction=INTERVENED. So your claim that InvokeModel headers give enforcement parity without giving up prompt caching / thinking / 1M context holds up. Guardrail deleted afterwards.

1. The detection won't fire

Look at the stop_reason in both runs above: end_turn, blocked or not. InvokeModel does not change it the way Converse does. This PR adds

"guardrail_intervened": "content_filter",   # transports/anthropic.py _STOP_REASON_MAP

and adds "guardrail_intervened" to the empty-content branch of the has-content check. Neither can ever trigger on this route — guardrail_intervened is a Converse stopReason, and this PR deliberately keeps Claude off Converse. The observable signal is in the response body:

model_extra = {"amazon-bedrock-guardrailAction": "INTERVENED",
               "amazon-bedrock-trace": {"guardrail": {"input": {"<gid>": {"wordPolicy":
                 {"customWords": [{"match": "zorblax", "action": "BLOCKED", ...

The Anthropic SDK keeps it as an unmodelled extra field, so getattr(response, "model_extra", {}) or {} reaches it — I verified that specific access, it is not a guess.

Worth fixing rather than deferring, because the current failure mode is the bad one: the headers do block, so BLOCKED_BY_GUARDRAIL_INPUT comes back as ordinary assistant text with a normal finish reason, and the agent then reasons over it as if the model had said it. A guardrail that silently substitutes text is harder to debug than one that visibly refuses.

2. It no longer applies to main

  • merge-base 9df5f879b4, 17,633 commits behind 87cc4de430
  • a real git merge --no-ff into today's main conflicts in 7 of 8 files: agent/agent_init.py, agent/anthropic_adapter.py, agent/chat_completion_helpers.py, agent/transports/anthropic.py, hermes_cli/runtime_provider.py, tests/agent/test_bedrock_integration.py, tests/agent/test_pet_generate.py
  • more importantly, the code you patch has moved. git grep guardrail up_ssh/main -- hermes_cli/runtime_provider.py is now empty; the Bedrock routing and the guardrail_config build live in hermes_cli/runtime_provider_backends.py (_bedrock_guardrail_config at :172, the is_anthropic_bedrock_model(...) and not has_bearer_token branch at :218). Resolving the textual conflict in runtime_provider.py would leave the live path untouched.

I mention that second point specifically because a clean-looking git merge-tree misled me here first — it reported no conflicts for this PR, and the real merge found seven. Worth rebasing off an actual merge, not a merge-tree probe.

Two smaller notes on the rebase target:

  • Your removal of if guardrail_config: runtime["guardrail_config"] = guardrail_config is right, and it is still dead on main: _runtime_agent_kwargs() in gateway/run.py is an explicit whitelist that does not include the key, so it is set on every Bedrock route and read on none. The Converse route only works because _init_bedrock_client re-reads config.yaml itself.
  • openai.gpt-5.6-sol and friends resolve to api_mode="codex_responses" on Bedrock, which drops the guardrail the same way. Out of scope for this PR — flagging it so it does not look covered by "non-Claude models continue on Converse". (openai.gpt-oss-120b-1:0 does stay on Converse; the split is is_openai_bedrock_model.)

Full route table and the end-to-end reproduction are on #52179. Happy to re-run any of this against a rebased head.

teknium1 added a commit that referenced this pull request Sep 11, 2026
…ce as refusals

bedrock.guardrail was only attached on the Converse route (guardrailConfig in the
body). Claude on Bedrock goes through the AnthropicBedrock SDK, i.e. InvokeModel,
whose body has no guardrailConfig, so the default Claude route ran with no guardrail
at all (#52179; live-verified by JiaDe-Wu: the blocked word came back through Hermes).

Bedrock reads the guardrail for InvokeModel from X-Amzn-Bedrock-GuardrailIdentifier /
-GuardrailVersion / -Trace headers. Attach them as default_headers in
build_anthropic_bedrock_client so every AnthropicBedrock client Hermes builds
(primary init, /model switch, fallback, per-request rebuild, auxiliary) enforces the
same guardrail, with prompt caching / thinking / 1M context kept (the reason Claude is
not routed through Converse).

InvokeModel blocks do NOT change stop_reason (stays end_turn) and return the guardrail's
canned text as an ordinary assistant reply, flagged only by
amazon-bedrock-guardrailAction=INTERVENED in the body (SDK: response.model_extra).
AnthropicTransport.response_finish_reason maps that to content_filter so the loop runs
its refusal handling instead of reasoning over the canned text; _derive_finish_reason
uses it for the anthropic_messages branch.

Mantle (openai.gpt-5.x) is documented by AWS as not supporting Guardrails on the
Responses endpoint; the docs now say so instead of promising "all model invocations".

Header mechanism proposed in #52312 by @JoaoMarcos44 (stale base, 7-file conflict,
detection keyed on a Converse-only stopReason); reimplemented on current main.

Live probe (local sink, SigV4 fake creds): before, no X-Amzn-Bedrock-* header on the
InvokeModel request; after, headers present, SigV4 intact, INTERVENED → content_filter.
teknium1 added a commit that referenced this pull request Sep 11, 2026
…ce as refusals

bedrock.guardrail was only attached on the Converse route (guardrailConfig in the
body). Claude on Bedrock goes through the AnthropicBedrock SDK, i.e. InvokeModel,
whose body has no guardrailConfig, so the default Claude route ran with no guardrail
at all (#52179; live-verified by JiaDe-Wu: the blocked word came back through Hermes).

Bedrock reads the guardrail for InvokeModel from X-Amzn-Bedrock-GuardrailIdentifier /
-GuardrailVersion / -Trace headers. Attach them as default_headers in
build_anthropic_bedrock_client so every AnthropicBedrock client Hermes builds
(primary init, /model switch, fallback, per-request rebuild, auxiliary) enforces the
same guardrail, with prompt caching / thinking / 1M context kept (the reason Claude is
not routed through Converse).

InvokeModel blocks do NOT change stop_reason (stays end_turn) and return the guardrail's
canned text as an ordinary assistant reply, flagged only by
amazon-bedrock-guardrailAction=INTERVENED in the body (SDK: response.model_extra).
AnthropicTransport.response_finish_reason maps that to content_filter so the loop runs
its refusal handling instead of reasoning over the canned text; _derive_finish_reason
uses it for the anthropic_messages branch.

Mantle (openai.gpt-5.x) is documented by AWS as not supporting Guardrails on the
Responses endpoint; the docs now say so instead of promising "all model invocations".

Header mechanism proposed in #52312 by @JoaoMarcos44 (stale base, 7-file conflict,
detection keyed on a Converse-only stopReason); reimplemented on current main.

Live probe (local sink, SigV4 fake creds): before, no X-Amzn-Bedrock-* header on the
InvokeModel request; after, headers present, SigV4 intact, INTERVENED → content_filter.
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks @JoaoMarcos44, the header mechanism was right and is now on main via #107815 (f845624), with credit in the commit and PR body.

Why a reimplementation rather than a rebase: the base was ~17k commits behind with conflicts in 7 of 8 files, the Bedrock routing had moved to hermes_cli/runtime_provider_backends.py, and the block detection was keyed on the Converse-only guardrail_intervened stopReason, which InvokeModel never emits (it returns end_turn and flags the block in amazon-bedrock-guardrailAction). The landed version attaches the headers at the client level so every AnthropicBedrock client gets them, and maps the INTERVENED body field to content_filter.

Closing as superseded by #107815.

@teknium1 teknium1 closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists provider/bedrock AWS Bedrock (boto3, IAM) sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bedrock Guardrails (bedrock.guardrail.*) configured but never enforced on any path (v0.17.0)

5 participants