Repository navigation
fix(aicore): prevent content filter bypass in multi-module and streaming modes - #410
Open
NicoleMGomes wants to merge 5 commits into
Open
NicoleMGomes wants to merge 5 commits into
NicoleMGomes wants to merge 5 commits into
Conversation
lenin-ribeiro
previously approved these changes
Oct 19, 2026
The streaming ContentFilteredError was always raised with request_id=None. The SAP orchestration v2 SSE format includes request_id in every chunk (surfaced as chunk.id by the LiteLLM SAP handler), so extract it from the filtered chunk for auditability.
Wrapping the stream iterator only protected callers using the SDK's completion() wrapper. ChatLiteLLM and LangGraph agents call litellm.completion directly, bypassing the wrapper entirely, so content_filter chunks would be silently yielded. Moving the guard into FilteringOrchestrationConfig.transform_request makes it universal — it fires before the HTTP request is sent regardless of the caller, because transform_request runs at the LiteLLM transport layer.
NicoleMGomes
commented
Oct 19, 2026
NicoleMGomes
left a comment
Contributor
Author
There was a problem hiding this comment.
PR Review: #410 — fix(aicore): prevent content filter bypass in multi-module and streaming modes
Author: NicoleMGomes Branch: fix/stream-filtering → main
Verdict:
Summary: Solid security fix with good test coverage; blocked only by a ruff-format failure in CI and two minor code-quality issues worth addressing before merge.
A: Process & Compliance
| # | Criterion | Status | Finding |
|---|---|---|---|
| A1 | PR template complete | Two checklist items unchecked: "I have verified that my changes solve the issue" and "All tests pass locally" — CI shows tests pass, but the local-verification boxes are unticked. Issue placeholder Closes #<issue_number> was not replaced with a real issue number. |
|
| A2 | Conventional Commits | First commit fix: content filtering with stream is missing the (aicore) scope; subsequent commits are correctly scoped. PR title is correctly scoped — commit-validation CI passes because it checks the PR title. Recommend amending or squashing before merge. |
|
| A3 | Issue linked | Closes #<issue_number> is a literal placeholder — no real issue linked. |
|
| A4 | AI-generated code disclosure | ✅ | No disclosure needed; the "Additional Notes / Code Review / Security Review" sections in the PR body are self-authored analysis, not AI-generated code. |
B: Security & Sensitive Data
| # | Criterion | Status | Finding |
|---|---|---|---|
| B1 | No sensitive data in code | ✅ | No credentials, SAP-internal URLs, tenant IDs, or customer data in the diff. |
| B2 | No sensitive data in PR body | ✅ | PR body contains no account-specific URLs, real tenant IDs, or internal tooling references. |
C: Code Quality
| # | Criterion | Status | Finding |
|---|---|---|---|
| C1 | CI checks passing | ❌ | Code Quality Checks fails: ruff-format requires the multi-line if condition at filtering/_patch.py:94–97 to be reformatted as a single logical line split differently. All other jobs (Tests, Build, CodeQL, Commit Validation, Version Bump) pass. |
| C2 | Version bump | ➖ | No manual version bump — N/A. |
| C3 | Type hints | transform_request return type is dict (inherited, fine); _install param cfg is annotated Any instead of ContentFiltering | None — minor but public-facing. |
|
| C4 | No hardcoded values | ✅ | No magic strings or magic numbers introduced; "content_filter" and "output" were pre-existing constants in context. |
| C5 | Import organization | from sap_cloud_sdk.aicore.filtering import _patch as _p at test_filtering.py:148 is imported but never used in _call_transform_request — dead import. |
|
| C6 | Naming conventions | ✅ | No new enums or module-level lists introduced; existing conventions followed. |
| C7 | No unused code | Unused import _p at tests/aicore/filtering/unit/test_filtering.py:148 (see C5). The noqa: S7503 comment at test_completion.py:339 suppresses a non-existent Ruff rule — harmless but noisy. |
|
| C8 | No unjustified new dependencies | ✅ | No new dependencies added. |
| C9 | Proto code freshness | ➖ | No .proto files changed — N/A. |
D: API & Design
| # | Criterion | Status | Finding |
|---|---|---|---|
| D1 | API future-proofing | ✅ | No new config params added; the guard is a behaviour change inside an existing class. |
| D2 | Public API hygiene | ✅ | completion.py __all__ unchanged; no new public symbols exposed. |
| D3 | Breaking changes marked | ✅ | stream=True with output filtering active now raises ContentFilteredError where it previously silently yielded chunks — this is a bug fix, not a breaking change, and the PR correctly does not tick the breaking-change checkbox. |
| D4 | Pagination & tenant filtering | ➖ | Not applicable to this change. |
| D5 | Telemetry instrumentation | ➖ | No new client methods added — N/A. |
| D6 | Multi-tenancy support | ✅ | user-guide.md already contains a compliant ## Multi-tenancy section (line 554); no change required for this fix. |
E: Tests & Documentation
| # | Criterion | Status | Finding |
|---|---|---|---|
| E1 | Tests added/updated | ✅ | TestTransformRequestMultiModule (3 tests in test_filtering.py) and TestStreamingGuard (7 tests in test_completion.py) cover the two new code paths. autouse fixture correctly restores _active_cfg after each test. |
| E2 | Documentation quality | ✅ | completion.py module docstring and user-guide.md Streaming section both updated to describe the new stream=True + output-filter constraint. The prose is accurate and actionable. |
| E2a | Multi-tenancy documentation | ✅ | Pre-existing ## Multi-tenancy section in user-guide.md:554 has all four required fields (Supported / Authentication / How to use / Further reading). Not modified by this PR but remains compliant. |
| E3 | Module structure compliance | ➖ | Not a new module — N/A. |
❌ Blocking Issues
- [C1]
src/sap_cloud_sdk/aicore/filtering/_patch.py:94–97: ruff-format requires theifcondition to be reformatted. Runruff format src/sap_cloud_sdk/aicore/filtering/_patch.pyand push —Code Quality ChecksCI job is currently failing because of this one file.
⚠️ Non-Blocking Suggestions
- [A1 / A3] Replace
Closes #<issue_number>with the real issue number (or remove the line if no issue exists), and tick the two unchecked checklist items. - [A2] Amend or squash the first commit (
fix: content filtering with stream) to add the(aicore)scope for consistency with the rest of the repo history. - [C5 / C7] Remove the unused
from sap_cloud_sdk.aicore.filtering import _patch as _pimport attests/aicore/filtering/unit/test_filtering.py:148. - [C3] Consider tightening
_install'scfg: Anyannotation tocfg: ContentFiltering | Nonefor IDE support and static analysis clarity.
✅ Things Done Well
- The transport-layer approach (
transform_request) is the right architectural choice: it is universal and caller-agnostic, coveringChatLiteLLMand LangGraph without any wrapper changes. - The multi-module loop fix (
for m in modules) is minimal and correct — the oldmodules[0]-only assignment was a clear bypass path. TestStreamingGuardcorrectly tests the boundary between input-only (allowed) and output-active (blocked), and theautousefixture ensures no test state leaks.- The updated
user-guide.mdStreaming section is clear and includes a runnable code example with thetry/except ContentFilteredErrorpattern. - The PR body's "Additional Notes" sections show thorough self-review including the thread-safety and
choices[0]-only edge cases.
Remove unused imports flagged by ruff (F401) and reformat the multi-line if condition in _patch.py to satisfy ruff format.
cassiofariasmachado
approved these changes
Oct 19, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes two content-filtering bypass paths in
sap_cloud_sdk.aicore:Multi-module bypass — when
fallback_sap_modulesis configured,body["config"]["modules"]is a list rather than a dict. The previous code only injectedfilteringintomodules[0]; all fallback entries were sent without any filter configuration. The patch now iterates over all entries.Streaming output-filter bypass — LiteLLM never calls
transform_responsefor streaming responses; it routes streams through a separate iterator path. This meant output-filter rejections (finish_reason == "content_filter"chunks) would be silently yielded to the caller rather than raisingContentFilteredError. The fix blocksstream=Trueupfront intransform_requestwhen output filtering is active, raisingContentFilteredErrorbefore any request is sent. This guard runs at the LiteLLM transport layer and covers all callers uniformly —sap_cloud_sdk.aicore.completion,ChatLiteLLM, LangGraph, etc.Input-only filtering is unaffected:
stream=Truewith input filtering active continues to work normally — input rejections surface asContentFilteredErrorthrough the HTTP 400 path regardless of streaming.Related Issue
Closes #<issue_number>
Type of Change
How to Test
Multi-module fix — call
set_filtering(ContentFiltering(...))then invokecompletion()with afallback_sap_modulesdeployment. Confirm filtering is present in allmoduleslist entries by inspecting the request body (or runTestTransformRequestMultiModuleunit tests).Streaming guard — activate output filtering via
set_filtering(ContentFiltering(output_filtering=OutputFiltering(...))), then callcompletion(model=..., messages=..., stream=True). Confirm aContentFilteredErrorwithdirection="output"is raised before any network request is made.Input-only streaming still works — activate input-only filtering, call with
stream=True, confirm no exception is raised upfront (filter rejections only arrive if the server returns HTTP 400).Run the unit test suite:
uv run python3 -m pytest tests/aicore/filtering/unit/test_filtering.py tests/aicore/unit/test_completion.py -vChecklist
Additional Notes
Security Review
body["config"]["stream"]["enabled"]andbody["config"]["modules"]are accessed without existence checks. If a future LiteLLM version changes this shape, the guard could silently fail. The current LiteLLM SAP handler (litellm/llms/sap/chat/handler.py:250-254) sets this path unconditionally, so the risk is limited to LiteLLM upgrades. Mitigation: the.get()chain on the stream key is already safe;modulesaccess is protected by the enclosingif filtering_dictcheck._active_cfg:_active_cfgis read twice between the output-filtering check and the module injection. In a multi-threaded scenario, filtering could be disabled between the two reads. In practice,set_filtering/disable_filteringare called at startup, not concurrently with requests.