diff --git a/apis/src/openai/responses/compact/config.rs b/apis/src/openai/responses/compact/config.rs index afe9c8f06..63fd40706 100644 --- a/apis/src/openai/responses/compact/config.rs +++ b/apis/src/openai/responses/compact/config.rs @@ -22,6 +22,15 @@ const DEFAULT_STATUS_ON_ERROR: u16 = 502; #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct CompactFilterConfig { + /// Allow summarization callouts from the `StreamBuffer` pre-read + /// phase, before header-phase security filters execute. + /// + /// This must be explicitly enabled only when an outer trust + /// boundary authenticates and authorizes requests before they + /// reach this listener. + #[serde(default)] + pub allow_pre_security_callout: bool, + /// URL of the inference backend for summarization calls. /// E.g., `"http://localhost:11434/v1/chat/completions"` pub inference_url: String, @@ -86,10 +95,12 @@ const SUPPORTED_ENCODINGS: &[&str] = &["cl100k_base", "o200k_base"]; /// /// # Errors /// -/// Returns [`FilterError`] if `inference_url` is empty, -/// `tiktoken_encoding` is not a supported encoding name, -/// `timeout_ms` is zero, or `status_on_error` is out of range. +/// Returns [`FilterError`] if `allow_pre_security_callout` is not +/// `true`, `inference_url` is empty, `tiktoken_encoding` is not a +/// supported encoding name, `timeout_ms` is zero, or +/// `status_on_error` is out of range. pub(super) fn build_config(raw: &CompactFilterConfig) -> Result { + validate_pre_security_callout(raw)?; if raw.inference_url.is_empty() { return Err(FilterError::from("openai_responses_compact: inference_url is empty")); } @@ -123,6 +134,17 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result Result<(), FilterError> { + if !cfg.allow_pre_security_callout { + return Err( + "openai_responses_compact: 'allow_pre_security_callout' must be true because StreamBuffer body callouts run before header-phase security filters; place authentication and authorization in an outer trust boundary" + .into(), + ); + } + Ok(()) +} + #[cfg(test)] #[expect(clippy::allow_attributes, reason = "blanket test suppressions")] #[allow(clippy::expect_used, clippy::unwrap_used, reason = "tests")] @@ -151,4 +173,14 @@ mod yaml_tests { serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions").expect("should deserialize"); assert_eq!(cfg.callout_failure_mode, None); } + + #[test] + fn pre_security_callout_defaults_to_false() { + let cfg: CompactFilterConfig = + serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions").expect("should deserialize"); + assert!( + !cfg.allow_pre_security_callout, + "pre-security callouts must be disabled until explicitly acknowledged" + ); + } } diff --git a/apis/src/openai/responses/compact/mod.rs b/apis/src/openai/responses/compact/mod.rs index b4b365325..8a6f232dc 100644 --- a/apis/src/openai/responses/compact/mod.rs +++ b/apis/src/openai/responses/compact/mod.rs @@ -18,6 +18,11 @@ //! `conversation`. Single-turn requests (no stored history, even with //! `context_management` set) are released without compaction because //! there is no prior history to summarize. +//! +//! Praxis runs `StreamBuffer` body hooks before header-phase request +//! filters. Configuration therefore requires an explicit +//! `allow_pre_security_callout: true` acknowledgement and should only +//! be used behind an outer authentication and authorization boundary. pub(super) mod config; @@ -93,10 +98,16 @@ struct CompactionParams { /// `openai_responses_rehydrate` has loaded stored conversation /// history. Single-turn requests are released without compaction. /// +/// Praxis runs `StreamBuffer` body hooks before header-phase request +/// filters. This filter therefore requires +/// `allow_pre_security_callout: true` and should only be used behind +/// an outer authentication and authorization boundary. +/// /// # YAML /// /// ```yaml /// filter: openai_responses_compact +/// allow_pre_security_callout: true /// inference_url: "http://localhost:11434/v1/chat/completions" /// default_model: llama3.2:1b /// ``` @@ -105,6 +116,7 @@ struct CompactionParams { /// /// ```yaml /// filter: openai_responses_compact +/// allow_pre_security_callout: true /// inference_url: "http://localhost:11434/v1/chat/completions" /// default_model: gpt-4o-mini /// tiktoken_encoding: cl100k_base diff --git a/apis/src/openai/responses/compact/tests.rs b/apis/src/openai/responses/compact/tests.rs index c96ca77c4..3fbaed289 100644 --- a/apis/src/openai/responses/compact/tests.rs +++ b/apis/src/openai/responses/compact/tests.rs @@ -12,6 +12,7 @@ use crate::openai::responses::config_validation::FailureMode; fn base_config() -> CompactFilterConfig { CompactFilterConfig { + allow_pre_security_callout: true, inference_url: "http://localhost:11434/v1/chat/completions".to_owned(), default_model: "gpt-4o-mini".to_owned(), tiktoken_encoding: "cl100k_base".to_owned(), @@ -32,6 +33,42 @@ fn build_config_applies_defaults() { assert_eq!(cfg.callout.status_on_error, 502); } +#[test] +fn build_config_rejects_missing_pre_security_ack() { + let mut cfg = base_config(); + cfg.allow_pre_security_callout = false; + let err = build_config(&cfg).unwrap_err(); + assert!( + err.to_string().contains("allow_pre_security_callout"), + "should mention allow_pre_security_callout: {err}" + ); +} + +#[test] +fn from_config_missing_pre_security_ack() { + let yaml = + serde_yaml::from_str::("inference_url: http://localhost/v1/chat/completions").unwrap(); + let err = CompactFilter::from_config(&yaml) + .err() + .expect("should fail without allow_pre_security_callout"); + assert!( + err.to_string().contains("allow_pre_security_callout"), + "should mention allow_pre_security_callout: {err}" + ); +} + +#[test] +fn from_config_accepts_pre_security_ack() { + let yaml = serde_yaml::from_str::( + "allow_pre_security_callout: true\ninference_url: http://localhost/v1/chat/completions", + ) + .unwrap(); + assert!( + CompactFilter::from_config(&yaml).is_ok(), + "explicit allow_pre_security_callout should construct" + ); +} + #[test] fn build_config_rejects_empty_inference_url() { let mut cfg = base_config(); @@ -432,7 +469,7 @@ fn conversation_text_skips_empty_compaction_summary() { fn make_filter(failure_mode: &str) -> CompactFilter { let yaml = serde_yaml::from_str::(&format!( - "inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: {failure_mode}" + "allow_pre_security_callout: true\ninference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: {failure_mode}" )) .unwrap(); let cfg: CompactFilterConfig = serde_yaml::from_value(yaml).unwrap(); diff --git a/docs/filters/openai_responses_compact.md b/docs/filters/openai_responses_compact.md index 20ee97e98..ef35e3f6f 100644 --- a/docs/filters/openai_responses_compact.md +++ b/docs/filters/openai_responses_compact.md @@ -11,10 +11,13 @@ Summarizes conversation history when the token count exceeds a configured thresh Compaction only applies to multi-turn requests where `openai_responses_rehydrate` has loaded stored conversation history. Single-turn requests are released without compaction. +Praxis runs `StreamBuffer` body hooks before header-phase request filters. This filter therefore requires `allow_pre_security_callout: true` and should only be used behind an outer authentication and authorization boundary. + ## Configuration | Field | Type | Required | Description | |-------|------|---------|-------------| +| `allow_pre_security_callout` | bool | no | Allow summarization callouts from the `StreamBuffer` pre-read phase, before header-phase security filters execute. This must be explicitly enabled only when an outer trust boundary authenticates and authorizes requests before they reach this listener. | | `inference_url` | string | yes | URL of the inference backend for summarization calls. E.g., `"http://localhost:11434/v1/chat/completions"` | | `default_model` | string | no | Default model for summarization when not overridden in the request's `context_management`. | | `tiktoken_encoding` | string | no | Tiktoken encoding name for local token estimation of the conversation text. | @@ -28,6 +31,7 @@ Compaction only applies to multi-turn requests where `openai_responses_rehydrate ```yaml filter: openai_responses_compact +allow_pre_security_callout: true inference_url: "http://localhost:11434/v1/chat/completions" default_model: llama3.2:1b ``` @@ -36,6 +40,7 @@ default_model: llama3.2:1b ```yaml filter: openai_responses_compact +allow_pre_security_callout: true inference_url: "http://localhost:11434/v1/chat/completions" default_model: gpt-4o-mini tiktoken_encoding: cl100k_base diff --git a/examples/configs/openai/responses/compact.yaml b/examples/configs/openai/responses/compact.yaml index ee8d51271..7c930ae40 100644 --- a/examples/configs/openai/responses/compact.yaml +++ b/examples/configs/openai/responses/compact.yaml @@ -3,6 +3,11 @@ # Demonstrates the compaction flow: store a response, rehydrate it # on the next turn, and count tokens to check if compaction is needed. # +# Security: StreamBuffer body callouts run before this listener's +# header-phase filters. Deploy this example behind an outer +# authentication and authorization boundary before enabling the +# required allow_pre_security_callout acknowledgement below. +# # Usage: # cargo run -p praxis-ai-proxy -- -c examples/configs/openai/responses/compact.yaml # @@ -51,6 +56,7 @@ filter_chains: - filter: openai_responses_rehydrate - filter: openai_responses_compact + allow_pre_security_callout: true inference_url: "http://localhost:11434/v1/chat/completions" default_model: llama3.2:1b timeout_ms: 60000