Skip to content

feat(bedrock): honor ANONYMIZE — mask-and-continue instead of block - #719

Merged
jarvis9443 merged 5 commits into
mainfrom
feat/bedrock-pii-mask
Jul 6, 2026
Merged

feat(bedrock): honor ANONYMIZE — mask-and-continue instead of block#719
jarvis9443 merged 5 commits into
mainfrom
feat/bedrock-pii-mask

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Problem

A Bedrock guardrail configured with PII action Anonymize is unusable through the gateway: Bedrock reports action=GUARDRAIL_INTERVENED for both a hard block and an anonymization, and kind=bedrock treated every intervention as a block. Operators who chose "mask the PII and continue" got a 422 instead of the masked conversation.

Implementation

One ApplyGuardrail call per hook now carries the request's/response's text slots as one content block each (instead of a single joined blob), so Bedrock's outputs[i] aligns positionally with slot i — the same contract LiteLLM relies on. The response is classified via the per-entity assessments[] actions:

  • any BLOCKED disposition (topic/content/word/contextual-grounding policy hit, or a BLOCKED PII/regex entity) → still blocks;
  • ANONYMIZED-only → the masked outputs[] texts are written back into the wire body and the request/response continues;
  • misaligned outputs[] (count ≠ input blocks) → defensive fallback: the mask is not applied, originals stand, the request continues (LiteLLM _merge_masked_texts semantics — never misapply masked text to the wrong slot).

Mechanics: the Guardrail trait gains moderate_{input,output}_segments (verdict + positional mask + entity counts from one remote call) and check_{input,output}_non_segment; call sites pair the two so a segment member is consulted exactly once per hook. Slot enumeration reuses the #932 wire walkers via collect/apply probe guardrails (SegmentCollector/SegmentApplier in redact.rs), so collect and write-back order are identical by construction across every wire shape, including streaming hold-back (chunk channels and SSE frames). Masked entity types (e.g. EMAIL) merge into redacted_entity_counts; assessment match values are never read (#153/#932 no-leak).

Scope: the chat-shaped families — /v1/chat/completions (incl. cache hits, ensemble, streaming), /v1/messages (passthrough + bridge, non-streaming + SSE), /v1/responses (passthrough + bridge, non-streaming + SSE), /v1/completions. Families with no mask write-back channel (embeddings, rerank, images, audio, raw passthrough, MCP) keep blob mode, where ANONYMIZE still maps to Block — releasing un-masked content there would defeat the operator's policy.

Behavior change

Bedrock guardrails whose matched policy action is ANONYMIZE previously blocked the request/response; they now mask and continue on the families above. Hard-block policies, fail-open/closed handling, timeouts, and throttle tagging are unchanged. No new config surface (masking is driven by the Bedrock guardrail's own configuration, matching LiteLLM), hence no CP changes.

Streaming content capture (AISIX-Cloud#947 invariant) is preserved: after a provider-side mask, the capture accumulator is rebuilt from the masked chunks/SSE channels so exporters never see pre-mask text.

Tests

  • guardrails crate: classification (empty-output positions preserved, counts carry types not values), apply_segments wiremock tests (one call with N blocks, positional mask, misalign fallback, hard block, fail-open/closed, blob mode still blocks on anonymize), chain segment folds (compose, attribute, refuse drifted masks).
  • proxy: collect→call→apply round trips over the chat walker and Anthropic SSE walker (positional slots index-stamped), skip-when-blocked/no-member.
  • e2e (bedrock-anonymize-mask-e2e.test.ts, mock Bedrock): request masked per slot before the upstream, reply masked non-streaming + across streaming chunk boundaries, BLOCKED still 422s pre-upstream, misalign fallback passes originals, /v1/messages body masked before the bridge.

Follow-up to the kind=pii work in AISIX-Cloud#932 (Bedrock external one-way masking half).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Bedrock guardrail support for content masking, with safer handling when masked output can’t be matched back to the original text.
    • Expanded moderation to work across chat, completions, messages, and responses flows, including streaming and non-streaming paths.
  • Bug Fixes

    • Improved blocking behavior for hard guardrail violations.
    • Prevented misaligned masked content from corrupting user-facing responses.
    • Kept redaction counts and rewritten content consistent across streaming and cached responses.

Bedrock reports action=GUARDRAIL_INTERVENED for both a hard block and a
PII anonymization. Add classify_response() to tell them apart via the
per-policy assessment actions (topic/content/word/contextual hits and
PII/regex action=BLOCKED are hard blocks; PII action=ANONYMIZED with
masked outputs is a mask), mirroring LiteLLM's block-iff-any-BLOCKED rule.

Foundational only: apply() still blocks on a mask (fail-safe, behaviour-
preserving) until the async rewrite channel that writes the masked
outputs[].text back onto the request/response lands.

Refs api7/AISIX-Cloud#932
…tion

Bedrock reports GUARDRAIL_INTERVENED for both block and mask; until now
any intervention blocked. Now the four chat-shaped families (chat,
messages, responses incl. bridge, completions) run a segment pass: the
request/response text slots are sent as one content block each in a
single ApplyGuardrail call (positional outputs[] contract), a hard-block
disposition still blocks, and an ANONYMIZE disposition writes Bedrock's
masked text back into the wire body and continues — LiteLLM's semantics,
including its defensive fallback (misaligned outputs are never applied;
originals stand).

- Guardrail trait: moderates_segments + moderate_{input,output}_segments
  (SegmentsOutcome carries verdict + positional mask + entity counts) and
  check_{input,output}_non_segment so segment members are consulted
  exactly once per hook; GuardrailChain folds both passes with the usual
  attribution/bypass semantics and refuses drifted masks.
- bedrock.rs: send() takes N content blocks; apply_segments() classifies
  block-vs-mask per assessments and aligns outputs[i]↔texts[i]; blob-mode
  check_* keeps mapping ANONYMIZE→Block for families with no write-back
  channel (embeddings/rerank/images/audio/passthrough/MCP unchanged).
- proxy redact.rs: SegmentCollector/SegmentApplier reuse the #932 wire
  walkers for slot enumeration (collect→call→apply, same order by
  construction); moderate_body() folds the split verdicts; streaming
  hold-back paths rebuild the content-capture accumulator from the
  masked chunks/SSE so exporters never see pre-mask text (#947).
- redacted_entity_counts gains Bedrock entity TYPES (EMAIL, …) — names
  only, matched values are never read (#153/#932).

Part of the #932 Bedrock follow-up (AISIX-Cloud#932).
- redact.rs unit tests: collect→call→apply positional round trip over
  the chat walker (flat content / text block / tool-arg JSON slots each
  index-stamped), Block leaves the body untouched, prior-Block and
  no-segment-member skip the remote call, Anthropic-SSE channel masking
  via the marker-count gate, and the SSE capture-rebuild helpers.
- e2e (vitest, mock Bedrock ApplyGuardrail): request masked per slot
  before the upstream (multi-block INPUT call pinned), reply masked
  before the caller (non-streaming + split-across-chunks streaming),
  BLOCKED entity still 422s before the upstream, misaligned outputs[]
  fall back to originals-and-continue, /v1/messages Anthropic-native
  body masked before the bridge.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 871f424f-db2d-47e9-85f3-2472b1e398fb

📥 Commits

Reviewing files that changed from the base of the PR and between 07d4e90 and cc2f2bc.

📒 Files selected for processing (3)
  • crates/aisix-guardrails/src/bedrock.rs
  • crates/aisix-guardrails/src/chain.rs
  • crates/aisix-proxy/src/redact.rs
📝 Walkthrough

Walkthrough

This PR adds masking-aware segment moderation for Bedrock guardrails, introducing SegmentsOutcome, GuardrailVerdict::merged_with, and new Guardrail trait hooks (moderates_segments, moderate_input_segments, moderate_output_segments, non-segment checks). GuardrailChain folds segment moderation across members. A shared moderate_body bridge in redact.rs performs collect-call-apply write-back of masked text. All proxy dispatch paths (chat, completions, messages, responses, responses_bridge) are updated to use non-segment checks combined with moderate_body. New unit, integration, and e2e tests validate classification, masking, alignment fallback, and blocking behavior.

Changes

Segment-aware anonymization pipeline

Layer / File(s) Summary
Guardrail trait, SegmentsOutcome, and verdict merging
crates/aisix-guardrails/src/lib.rs
Adds SegmentsOutcome type, GuardrailVerdict::merged_with, and new Guardrail trait hooks for segment moderation with non-segment default behavior.
Bedrock classify_response, apply/apply_segments, hook wiring
crates/aisix-guardrails/src/bedrock.rs
Implements a shared send helper, classify_response/BedrockOutcome masking classification, blob vs segment apply flows, hook-gated segment moderation methods, and extensive unit/integration tests.
GuardrailChain segment fold and non-segment evaluation
crates/aisix-guardrails/src/chain.rs
Adds fold_segments and chain-level segment moderation/non-segment check methods enforcing Block short-circuit, Bypass stickiness, and mask alignment, with new tests.
redact.rs moderate_body write-back bridge
crates/aisix-proxy/src/redact.rs
Adds SegmentCollector/SegmentApplier passes, moderate_body, anthropic_sse_text, responses_sse_text, and matching tests.
chat.rs guardrail + redaction wiring
crates/aisix-proxy/src/chat.rs
Switches input/output guardrail checks across dispatch, cache-hit, upstream, ensemble, and streaming paths to non-segment checks plus moderate_body.
completions.rs guardrail + redaction wiring
crates/aisix-proxy/src/completions.rs
Updates input/output guardrail evaluation to non-segment checks with moderate_body and seg-count accumulation.
messages.rs guardrail + redaction wiring
crates/aisix-proxy/src/messages.rs
Updates Anthropic dispatch, passthrough, cross-provider, and SSE streaming paths to non-segment checks with moderate_body and response-text rebuilding.
responses.rs / responses_bridge.rs guardrail + redaction wiring
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Updates dispatch, streaming, non-streaming, and bridged paths to non-segment checks with moderate_body and held-buffer rewriting.
Bedrock anonymize mask e2e suite
tests/e2e/src/cases/bedrock-anonymize-mask-e2e.test.ts
Adds mock Bedrock server and tests covering masking, blocking, misalignment fallback, streaming, and /v1/messages anonymization.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dispatch as Proxy Dispatch
  participant Chain as GuardrailChain
  participant Bedrock as BedrockGuardrail
  participant ModerateBody as redact::moderate_body

  Dispatch->>Chain: check_input_non_segment(req)
  Chain-->>Dispatch: verdict (Allow/Block/Bypass)
  Dispatch->>ModerateBody: moderate_body(chain, dir, verdict, walk)
  ModerateBody->>Chain: collect text slots
  ModerateBody->>Chain: moderate_input_segments(texts)
  Chain->>Bedrock: apply_segments(texts)
  Bedrock-->>Chain: SegmentsOutcome(masked, counts)
  Chain-->>ModerateBody: merged SegmentsOutcome
  ModerateBody->>Dispatch: rewritten body + merged counts
  Dispatch->>Dispatch: proceed with masked request/response
Loading

Possibly related PRs

  • api7/aisix#491: Both PRs modify crates/aisix-proxy/src/messages.rs's /v1/messages output-guardrail enforcement path, with this PR further refactoring it to check_output_non_segment plus segment-aware redaction.
  • api7/aisix#627: Both PRs modify crates/aisix-proxy/src/responses_bridge.rs's streaming output-guardrail path, with this PR switching it to segment-aware moderate_body rewriting.
  • api7/aisix#694: This PR builds directly on that PR's PII redaction/masking pipeline in crates/aisix-proxy/src/redact.rs, extending it with remote segment moderation and mask alignment.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security Check ❌ Error Block warnings log %reason, and nearby comments say it may be matched/forbidden text; that leaks sensitive content to tracing across chat/messages/responses. Remove reason from warn logs; log only guardrail name/hook/model or a redacted hash. Keep raw reasons behind restricted debug/sampled traces.
E2e Test Quality Review ⚠️ Warning Solid chat/messages E2E, but the PR also changes /v1/responses and /v1/completions and no Bedrock-mask E2E covers those flows. Add at least one end-to-end ANONYMIZE case for /v1/responses and /v1/completions, plus an empty/misaligned-slot boundary case.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: Bedrock ANONYMIZE handling now masks and continues instead of blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bedrock-pii-mask

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
tests/e2e/src/cases/bedrock-anonymize-mask-e2e.test.ts (1)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

bedrock!.calls lookup relies on hidden test-order assumptions.

bedrock.calls is a single array populated across beforeAll's warmup call and every subsequent test, never reset or scoped. Grabbing .filter(c => c.source === "INPUT").at(-1) only returns this test's call because tests happen to run sequentially in file order. Elsewhere in this same file (Line 305, 357, 423) you correctly snapshot receivedRequests.length before issuing the request and slice from that baseline — the same pattern isn't applied here, making this assertion fragile to reordering or an inserted guardrail retry.

♻️ Suggested fix: snapshot before the call, like the upstream-request assertions
+      const bedrockCallsBefore = bedrock!.calls.length;
       const r = await chat(CALLER, "bmask-e2e", [
         { role: "system", content: "be helpful" },
         { role: "user", content: `contact ${EMAIL} please` },
       ]);
       expect(r.status).toBe(200);
       ...
       const inputCall = bedrock!.calls
+        .slice(bedrockCallsBefore)
         .filter((c) => c.source === "INPUT")
         .at(-1)!;

As per path instructions for **/*.{test,spec,e2e}.{js,ts,jsx,tsx}: "Avoid explicit dependencies between tests and hidden execution order assumptions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/bedrock-anonymize-mask-e2e.test.ts` around lines 294 -
303, The INPUT assertion is depending on the shared bedrock.calls array and
hidden test execution order, which makes it fragile. In the affected test,
snapshot the current bedrock.calls length before issuing the request, then
assert only against the new calls added by this test instead of using
.filter(...).at(-1). Use the existing request-baseline pattern already used
elsewhere in bedrock-anonymize-mask-e2e.test.ts so the check is scoped to this
test case and not prior warmup or retries.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-guardrails/src/bedrock.rs`:
- Around line 194-201: The Bedrock request building in
moderate_*_segments()/send() is still adding GuardrailContentBlock::Text("") for
empty segment slots, which can break the whole request. Update the text loop in
bedrock.rs to skip or specially handle empty strings before calling
GuardrailTextBlock::builder() and req.content(...), while keeping outputs[i]
aligned with the original segment positions.
- Around line 398-420: The hard-block check in assessment_has_hard_block is
treating any topic/content/word match as a block even when the policy action is
NONE. Update the topic_policy, content_policy, and word_policy branches to gate
on each policy’s action the same way the contextual_grounding_policy and
sensitive_information_policy branches do, so detect-only assessments don’t force
classify_response to Block. Add or adjust a test covering NONE mode to verify
mask/continue flows remain non-blocking.

In `@crates/aisix-guardrails/src/chain.rs`:
- Around line 311-368: The fold_segments function is merging Redaction counts
even when a member’s masked output is rejected for being misaligned, which can
overstate applied anonymization. Update fold_segments so Redaction::merge_counts
only runs when the new masked output is accepted (or otherwise explicitly
handled), using the existing masked/new_masked logic and GuardrailVerdict flow
to keep counts aligned with applied masks. Add a test with a drifting stub that
returns non-empty counts and a misaligned mask to verify redacted_entity_counts
is not incremented unless the mask is actually accepted.

In `@crates/aisix-proxy/src/redact.rs`:
- Around line 1090-1106: The emission-order bug is in the `responses_sse_text`
aggregation path that builds the `key` from `item_id`, `output_index`, and
`content_index` before iterating via `BTreeMap::into_values()`. Replace the
lexicographic map-order reconstruction with an order-preserving approach, such
as tracking first-seen insertion order or explicitly sorting by `output_index`
and `content_index` before rebuilding `comp.response_text`, so multi-item
Responses streams reflect the original emission order.

---

Nitpick comments:
In `@tests/e2e/src/cases/bedrock-anonymize-mask-e2e.test.ts`:
- Around line 294-303: The INPUT assertion is depending on the shared
bedrock.calls array and hidden test execution order, which makes it fragile. In
the affected test, snapshot the current bedrock.calls length before issuing the
request, then assert only against the new calls added by this test instead of
using .filter(...).at(-1). Use the existing request-baseline pattern already
used elsewhere in bedrock-anonymize-mask-e2e.test.ts so the check is scoped to
this test case and not prior warmup or retries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3867497c-0de9-43ac-945f-efba34951f7a

📥 Commits

Reviewing files that changed from the base of the PR and between a09043e and 07d4e90.

📒 Files selected for processing (10)
  • crates/aisix-guardrails/src/bedrock.rs
  • crates/aisix-guardrails/src/chain.rs
  • crates/aisix-guardrails/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/redact.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/responses_bridge.rs
  • tests/e2e/src/cases/bedrock-anonymize-mask-e2e.test.ts

Comment thread crates/aisix-guardrails/src/bedrock.rs
Comment thread crates/aisix-guardrails/src/bedrock.rs
Comment thread crates/aisix-guardrails/src/chain.rs
Comment thread crates/aisix-proxy/src/redact.rs
CodeRabbit findings on #719:
- topic/content/word policy entries also have a detect-only mode
  (action=NONE, observability metadata): gate the hard-block decision on
  action=BLOCKED per entry instead of entry presence, matching LiteLLM's
  per-entry check — a detect-mode entry no longer turns an ANONYMIZE
  mask into a block.
- chain fold: merge a member's entity counts only when its mask is
  accepted — a refused (misaligned) mask must not inflate
  redacted_entity_counts.
- responses_sse_text: concatenate channels in first-seen emission order,
  not item-id lexicographic order.
@jarvis9443
jarvis9443 merged commit e9d702b into main Jul 6, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the feat/bedrock-pii-mask branch July 6, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant