Skip to content

feat(web-search): route provider callouts through outbound filter chain - #1187

Open
leseb wants to merge 2 commits into
praxis-proxy:mainfrom
leseb:leseb/fix-958-from-pr-1166-approved
Open

leseb wants to merge 2 commits into
praxis-proxy:mainfrom
leseb:leseb/fix-958-from-pr-1166-approved

Conversation

@leseb

@leseb leseb commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Migrates the shared web-search SearchClient so that both openai_web_search
(OpenAI Responses) and anthropic_web_search (Anthropic Messages) execute
their provider requests through a prebuilt outbound filter chain via the
reusable filtered-subrequest executor, instead of a raw client that bypassed
the filter pipeline. This is part of epic #879 (per-filter SSRF opt-in removal).

What changed

  • Outbound chain binding. Adds an outbound_chain config reference to the
    shared web-search config and both provider filters. Because these filters run
    as IRR steps whose step-pipeline chain map is empty, the chain must be
    inline; named top-level filter_chains refs only resolve at non-IRR
    placements.
  • SSRF opt-in removed. Removes allow_private_base_url (config field, the
    validate_base_url private-address branch, per-filter SSRF unit tests, and
    example lines). SSRF is now enforced by the executor at connect time and
    gated by insecure_options.allow_private_upstreams, propagated into the
    bound outbound chains via set_allow_private_upstreams. base_url is kept.
  • Caller context + depth. Threads the originating caller context (client
    address, downstream TLS, peer identity, request start) and the outbound
    depth into the executor. Depth is read from the IRR-owned IterationState,
    not from the x-praxis-* depth header (which the IRR strips before building
    the step context).
  • Credential protection. Defers provider credentials off the in-chain
    request: Brave's x-subscription-token and You's x-api-key are staged as
    host-wildcard DeferredCredentials and injected by the executor only after
    upstream resolution, so a chain filter cannot observe or retarget them.
    Combined with the staged-upstream pin, this also protects Tavily's body key
    against chain retargeting.
  • Preserves provider translation, failure policy, deadlines, response limits,
    aggregate budgets, destination authority, DNS/SSRF, TLS/SNI, and Host. DNS
    multi-address failover is retained via the staged fallback.

Testing

Unit, functional-integration, docs, and example coverage for both providers:

  • Outbound-filter execution proof (request-id header observed on the provider
    callout) for OpenAI and Anthropic.
  • Outbound-chain reject coverage.
  • Chain-retargeting credential-exfiltration proof (a retargeting filter cannot
    move a staged credential to an alternate backend).
  • Config validation (unresolvable / missing outbound_chain fails the build).

Dependency

Consumes the chain-binding infrastructure released in praxis-proxy 0.5.5
(FilteredSubrequestExecutor chain binding, StagedUpstream /
StagedUpstreamFallback, deferred credentials, IRR chain-aware step build).
The dependency bump to 0.5.5 is included in this PR.

Closes #958
Refs #879

@leseb
leseb force-pushed the leseb/fix-958-from-pr-1166-approved branch 3 times, most recently from 3bd521c to fcf60f8 Compare September 18, 2026 08:38
@leseb
leseb marked this pull request as ready for review September 18, 2026 13:01
@leseb
leseb requested review from a team and aslakknutsen September 18, 2026 13:01
Migrate the shared web-search `SearchClient` so that both
`openai_web_search` (OpenAI Responses) and `anthropic_web_search`
(Anthropic Messages) execute their provider requests through a prebuilt
outbound filter chain via the reusable filtered-subrequest executor,
instead of a raw client that bypasses the filter pipeline.

Key changes:

- Add an optional `outbound_chain` config reference (`ChainRef`) to the
  shared web-search config and both provider filters, matching
  `openai_file_search_callout`. When omitted it defaults to an empty
  inline chain (pure passthrough) via `default_outbound_chain`, so the
  callout still runs through the executor with every central protection
  but needs no configuration. When set, an inline chain binds for the IRR
  step placement used by the agentic loop, and a named top-level
  `filter_chains` entry (e.g. `web_search_outbound`) resolves where the
  placement allows it.
- Execute the callout with praxis 0.5.6 native caller-staging:
  `StagedUpstream`/`StagedUpstreamFallback::from_prepared_target` plus
  `FilteredSubrequestExecutor::run_classified`, so the bound chain runs
  against a pinned, pre-resolved upstream target.
- Remove the `allow_private_base_url` opt-in (config field, the
  `validate_base_url` private-address branch, per-filter SSRF unit tests,
  and example lines). SSRF is now enforced centrally by the executor at
  connect time, gated by `insecure_options.allow_private_upstreams` and
  propagated into the bound outbound chains. `base_url` is retained.
- Defer provider credentials off the in-chain request: Brave's
  `x-subscription-token` and You's `x-api-key` are staged as host-wildcard
  `DeferredCredential`s and injected by the executor only after upstream
  resolution, so a chain filter cannot observe or retarget them. Combined
  with the staged-upstream pin, this also protects Tavily's body key
  against chain retargeting.
- Fail the callout closed on `CalloutOutcome::ResponseTooLarge`
  (`SearchOutcome::Failed`) rather than surfacing an HTTP 413: a
  web-search callout feeds a tool result into the agentic loop and never
  returns a provider status to the client, so the model continues with a
  truthful "search unavailable" result.
- Preserve provider translation, failure policy, deadlines, response
  limits, aggregate budgets, destination authority, DNS/SSRF, TLS/SNI, and
  `Host`. DNS multi-address failover is retained via the staged fallback.

Unit, functional-integration, docs, and example coverage are included for
both providers (outbound-filter execution proof, omitted-chain default
passthrough, outbound-chain reject coverage, chain-retargeting
credential-exfiltration proof, and config validation).

Closes praxis-proxy#958
Refs praxis-proxy#879

Signed-off-by: Sébastien Han <seb@redhat.com>
@leseb
leseb force-pushed the leseb/fix-958-from-pr-1166-approved branch from fcf60f8 to 0d54528 Compare September 18, 2026 13:12
@leseb
leseb enabled auto-merge September 18, 2026 13:13
The agentic-loop example config already declares
`allow_private_upstreams: true` in its `insecure_options`, so the test
helpers that additionally injected the key produced a duplicate field.
praxis-core's strict YAML parser rejects duplicate keys, failing the
config load with "insecure_options: duplicate field
allow_private_upstreams" and breaking the coverage and vLLM Responses
integration suites (regression latent in the outbound-chain migration
that replaced the removed per-filter `allow_private_base_url` opt-in
with an `insecure_options` injection).

Remove the now-redundant injection from both Rust helpers
(`load_web_search_config`, `load_unified_dispatch_config`) and the
Python `_write_agentic_config`, replacing each with a comment noting the
operator opt-in already ships in the base config. The
`_write_web_search_chat_streaming_config` injection is retained because
its base config (web-search-chat-completions.yaml) does not declare the
key.

Refs praxis-proxy#958

Signed-off-by: Sébastien Han <seb@redhat.com>
@leseb
leseb added this pull request to the merge queue Sep 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Sep 18, 2026
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.

feat(callout): route web-search requests through filtered subrequests

2 participants