Repository navigation
feat: opt-in passthrough fallback when a policy modification causes an upstream 4xx - #797
Conversation
|
CHANGELOG reminder — This PR has no changelog fragment. Add a file to |
Review Status: No Changes to ReviewThis draft PR currently contains only the objective-setting empty commit ( Happy to review once implementation lands. A few things I'll be watching for when the code arrives, given the objective ("opt-in passthrough fallback when a policy modification causes an upstream 4xx"): Correctness / semantics
Opt-in surface
Security
Tests (per CLAUDE.md "Test Requirements")
Docs / changelog
Ping me once there's a diff and I'll do a real review. |
Code Review: Passthrough FallbackOverall this is a clean, focused PR that lands a well-scoped follow-up to the PR #204 audit. The trigger predicate is conservative in the right ways, the deep-copy snapshot is the right shape for the in-place-mutation case, and the observability + test coverage are solid. Findings below are minor. Correctness observations1.
Not blocking — but worth being explicit about for downstream observability. 2. Pre-existing in-place-mutation risk on 3. Streaming: async for mse in _iterate(final_request):
events_yielded += 1
yield mseIf a downstream consumer of Nits
Test coverageComprehensive. The parametrized One suggestion: add a test asserting the Design principleFalling back inside Style / conventions
LGTM with the observability note above worth considering before merge. Nothing here is blocking. |
|
Post-review note (adversarial verification pass, Jul 7): this PR and its sibling both add a one-shot upstream retry to the same two methods in |
|
Claude-generated merge-queue triage of all open Luthien PRs, requested by Scott (Jul 7, 2026). Advisory only; Scott has not yet acted on these recommendations. Recommendation: merge, after #799 is closed (see the comment there). Verified in the diff: |
…icy-caused upstream failures
…n upstream 4xx Implements https://trello.com/c/kRPRjGUx (COE audit follow-up to PR #204). Design principle: the proxy should never make things worse than direct API access. Trigger (all must hold): - PASSTHROUGH_FALLBACK_ENABLED is on (default off) - upstream AnthropicStatusError with status in {400, 404, 413, 422} - the request sent differs from the pristine pre-policy snapshot - (streaming) zero backend events received yet The fallback lives at the backend-call site (_AnthropicPolicyIO), so intentional policy blocks — which are response rewrites or policy-raised errors, never upstream errors — structurally cannot be overridden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hful audit record Adversarial review of #797 found two holes: 1. Fallback could undo redaction / request-side safety policies. A client could craft input so the policy's edit (e.g. stripping a secret, leaving an empty text block) triggers a 400, and the fallback then sent the unredacted original upstream. Same for a model-restriction policy + 404. Fix: double opt-in. The flag must be on AND the active policy must declare `passthrough_fallback_safe = True` (BasePolicy default False, fail-closed). MultiSerialPolicy requires every sub-policy to opt in. DogfoodSafetyPolicy opts in because it never edits requests (so dogfood mode does not veto an otherwise opted-in chain). No shipped request-rewriting policy opts in. 2. Audit record was wrong: transaction.request_recorded named the modified request as final_request even when the original was what upstream accepted. Fix: while fallback is armed the record is deferred until the first attempt resolves; after a fallback, final_request is the original and a `passthrough_fallback` block records the rejected request plus the upstream status and message. Also: accurate changelog wording (only response-side blocks are untouched; flag is runtime-settable by admin-key holders), e2e tests now pin the fail-closed behavior, decisions.md entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ec3fab5 to
4f857c7
Compare
|
Pushed fixes for the adversarial review findings (rebased on main after v4.0.0 first; clean rebase):
Checks: 🤖 Generated with Claude Code |
ReviewOverall this is a carefully-scoped PR with a strong security design and excellent test coverage. The double opt-in ( Correctness / minor concerns
Nice touches worth calling out
Test coverageVery thorough. Unit tests cover the request-shape / non-request-shape split, streaming connect vs. mid-stream, in-place mutation, response blocks, policy-raised errors, and the full audit-record matrix (fired / armed-but-no-fallback / armed-success, non-streaming and streaming). The policy-gate class exercises the redaction-attack path end to end. The one gap you already flag — no e2e positive fallback path because no shipped request-rewriting policy opts in — is unavoidable given the design and is documented in the PR description. Style / nits
🤖 Generated with Claude Code |
…fy requests; accurate audit wording Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review: opt-in passthrough fallback (PR #797)Overall this reads as a careful, well-scoped implementation of the P1 follow-up from the PR #204 COE. The double opt-in gate, the fail-closed default, and the invariant test that enforces "opted-in policies never modify requests" together make the security posture much stronger than a global flag would give you. The audit-record change (deferred Below is what I noticed on read-through. Nothing in the must-fix column — a couple of small suggestions and one clarification worth calling out. Correctness
Suggestions (non-blocking)
Test coverageExcellent. 28 unit tests covering: happy path, disabled path, unmodified-request short-circuit, non-request-shaped statuses (parametrized), retry-failure propagation, in-place mutation detection, streaming connect vs. mid-stream, intentional response blocks and policy-raised errors, snapshot-not-taken-when-disabled, and the full audit-record matrix (fallback fired / armed-but-no-fallback / armed-success / streaming variants). The The The one gap the PR itself calls out (no shipped opted-in request-rewriting policy, so no positive e2e path) is a reasonable limitation to ship with, given the unit-level positive path is well covered. Performance / security
Style / conventionsMatches CLAUDE.md conventions: Google-style docstrings on public methods, comments focus on the why (particularly good in Ready to approve modulo the small suggestions above. Great work on the adversarial-safety framing — this reads like a PR that survived several rounds of "what if a client tries to game this," which is exactly what you want from a fail-open-when-on feature. |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of PR #797 — opt-in passthrough fallbackOverall this reads as a careful, safety-first implementation. The double opt-in ( Things I liked
Suggestions
Not blocking, but worth thinking about later
Nothing here is a blocker. The safety story is solid, the tests do the enforcement work, and the failure modes I could think of are already covered or documented. |
|
Merging on Scott's decision (Sep 27) after an adversarial review found no blockers (composition, hot-swap, streaming, retries, audit-record loss, and secrets-in-logs paths all held). Added before merge: a unit test that fails if any shipped policy opts in to fallback while overriding a request hook (mutation-checked); accurate audit wording ('request sent last'); changelog note that no shipped request-editing policy opts in yet. Known non-blocking follow-ups: no e2e proof of the runtime admin toggle; history UI doesn't surface the rejected request after a fallback. 🤖 Generated with Claude Code |
Resolves the conflict with LuthienResearch#797 (opt-in passthrough fallback) in src/luthien_proxy/pipeline/anthropic_processor.py and its tests. Omp-Session: 01a11bc8-2d42-713b-89d9-99688f9a13f7
Resolves the conflict with LuthienResearch#797 (opt-in passthrough fallback) in src/luthien_proxy/pipeline/anthropic_processor.py: both features' helper methods are kept, and complete()/stream() record the request with the fallback's deferral and then tag its Sentry provenance. LuthienResearch#797's new tests build _AnthropicPolicyIO without the client_request_unmodified and credential_passthrough arguments this branch made required; they now pass both as True (a passthrough request, which is what those tests exercise). Omp-Session: 01a11bc8-2d42-713b-89d9-99688f9a13f7
Implements Trello: COE audit passthrough fallback (P1 follow-up from the 2026-03-25 COE audit of PR #204). Rebased on main after v4.0.0.
Summary
Opt-in passthrough fallback: when a policy-modified request is rejected upstream with a request-shaped 4xx, the gateway retries once with the original, pre-policy request. Design principle from the card: the proxy should never make things worse than direct API access, with the constraint that fail-open is acceptable pre-users only if it is opt-in, logged, and never undoes a redaction.
This is the narrow slice of PR #204 that survived its review: on failure it removes the policy's own modifications rather than adding new ones. No sanitization, no pattern-matched auto-fixes, no error rewriting.
Safety design: double opt-in, fail-closed by default
Adversarial review found that a global flag alone let a client undo request-side safety policies: craft input so the redaction leaves an invalid request (e.g. an empty text block after a secret is stripped), get a 400, and the fallback then sends the unredacted original upstream. A model-restriction policy could likewise be undone by a 404.
The fallback now requires both:
PASSTHROUGH_FALLBACK_ENABLEDon (default off; runtime-settable via the admin config API by anyone with the admin key), andpassthrough_fallback_safe = True(newClassVaronBasePolicy, defaultFalse).MultiSerialPolicyallows fallback only if every sub-policy opts in, so one redactor in a chain vetoes it. Policies that don't derive fromBasePolicycan't declare safety and are treated as fail-closed.Why a per-policy declaration rather than detecting "content was removed" at runtime: any request edit can be safety-relevant (a model swap removes nothing but restricts access), so a structural diff can't tell safety edits from cosmetic ones. Only the policy author knows, so the policy declares it, and the default is the safe answer.
What opts in today: only
DogfoodSafetyPolicy, which never edits requests; it opts in so dogfood mode (which auto-composes it into every chain) doesn't silently veto an otherwise opted-in chain. No shipped request-rewriting policy opts in (StringReplacementPolicywithapply_to: requestis exactly how a redactor is built, andSimplePolicysubclasses are user-defined). So in practice this PR ships the mechanism plus a fail-closed default; a policy whose request edits are harmless compatibility tweaks can opt in with one line.Trigger condition (all must hold)
AnthropicStatusErrorwith status in{400, 404, 413, 422}. Excluded: 401/403 (credential-scoped), 429 (retry amplifies load), 5xx/529 (SDK already retries)Exactly one retry. If the original also fails, that error propagates unchanged. Response-side policy behavior (response rewrites, blocks) still runs on the fallback response; only request-side edits are discarded.
Audit record now reflects what was actually sent
Previously
transaction.request_recordednamed the policy-modified request asfinal_requesteven when upstream received and accepted the original. Now, while the fallback is armed (allowed + request modified), that one-per-transaction record is deferred until the first attempt's outcome is known:final_request/final_model= the original (what upstream accepted), plus apassthrough_fallbackblock:{status_code, error_message, rejected_request}final_request= the modified request, as beforeUnchanged default path: when the fallback isn't armed, recording happens before the send exactly as on main. Both attempts still appear as
pipeline.backend_requestevents, the request log's outbound body is the resent original, and thepipeline.passthrough_fallbackevent, WARNING log, andluthien.passthrough_fallbackspan attribute are all still emitted before the retry.Interaction with #798 (known-bad request pattern regressions)
Those tests pin transparency: known-bad client bodies pass through verbatim and upstream 400s are relayed as clean
invalid_request_error. Unaffected: the fallback only fires when a policy changed the request, and it's off by default.test_known_bad_request_patterns.pypasses on the rebased branch.Judgment call flagged for review
The "original" snapshot is taken after the
INJECT_POLICY_CONTEXTsystem-prompt injection, so the fallback removes what the active policy changed, not the injection (unchanged from the first revision of this PR).Files changed
src/luthien_proxy/policy_core/base_policy.pypassthrough_fallback_safeClassVar (default False) +allows_passthrough_fallback()src/luthien_proxy/policies/multi_serial_policy.pysrc/luthien_proxy/policies/dogfood_safety_policy.pysrc/luthien_proxy/pipeline/anthropic_processor.py_AnthropicPolicyIO.complete()/stream(), policy gate_passthrough_fallback_allowed, deferred/truthful transaction recordsrc/luthien_proxy/config_fields.py,settings.py,.env.examplepassthrough_fallback_enabledfield (db-settable, no restart); description updatedtests/.../pipeline/test_anthropic_processor.pytests/.../e2e_tests/test_mock_passthrough_fallback.pychangelog.d/passthrough-fallback.mddev/context/decisions.mdTest plan
tests/luthien_proxy/unit_tests), including:test_redaction_plus_400_never_resends_secret)test_opted_in_policy_plus_400_falls_back)transaction.request_recordedwithfinal_request= original +passthrough_fallbackblock (non-streaming and streaming); armed-without-fallback records the modified request once./scripts/run_e2e.sh mock): 211 passed./scripts/dev_checks.shpasses (format, lint, pyright, unit tests)🤖 Generated with Claude Code