feat(inference): multi-provider LLM upstreams (Anthropic, Ollama) + pluggable guardrail pipeline (OpenAI Moderation) - #488
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends Kars’ inference plane to support policy-driven multi-provider routing (Anthropic, Ollama; Bedrock explicitly unimplemented) and introduces a pluggable guardrail pipeline (first backend: OpenAI Moderation) enforced for buffered and SSE streaming responses.
Changes:
- Adds
InferencePolicy.spec.provider(typed enum with kebab-case wire tags) andspec.guardrails[](ordered stages) with CRD/CEL validation, compilation into the router-consumed profile JSON, and updated docs/changelog. - Implements router-side provider resolution + per-provider proxy behavior (URL shapes + auth schemes), keeping credentials router-side only.
- Implements guardrail pipeline enforcement for input/output, including hold-and-release scanning for SSE streams, plus metrics for scan outcomes.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| inference-router/tests/proxy_fake_upstream.rs | Updates tests to populate new UpstreamConfig fields (provider, api_key). |
| inference-router/tests/policy_status_endpoint.rs | Extends test router Config literals with new provider/guardrail fields. |
| inference-router/tests/multi_provider_guardrails.rs | Adds end-to-end wiremock tests for Anthropic/Ollama forwarding and moderation guardrail behavior. |
| inference-router/tests/failover_walk.rs | Updates failover tests for expanded UpstreamConfig. |
| inference-router/tests/egress_blocked_endpoint.rs | Extends test Config literals with new provider/guardrail fields. |
| inference-router/tests/agt_governance_integration.rs | Extends test Config literals with new provider/guardrail fields. |
| inference-router/src/routes/mod.rs | Adds apply_provider_resolution to retarget upstream per policy/provider precedence. |
| inference-router/src/routes/chat_completions.rs | Enforces provider selection + guardrails for chat completions, including streaming and recovery paths. |
| inference-router/src/routes/anthropic_messages.rs | Applies provider resolution + guardrails to the Anthropic Messages route (pass-through + translated paths). |
| inference-router/src/proxy.rs | Makes proxy forwarding provider-aware (auth header scheme + URL shaping) and adds UpstreamConfig::azure constructor. |
| inference-router/src/provider.rs | Introduces provider tag parsing/resolution with fail-closed semantics and config-backed targets. |
| inference-router/src/metrics.rs | Adds kars_guardrail_scans_total{provider,direction,outcome} metric. |
| inference-router/src/lib.rs | Exports new guardrails and provider modules. |
| inference-router/src/inference_policy_loader.rs | Loads compiled policy provider + guardrails fields into snapshots for per-request enforcement. |
| inference-router/src/guardrails.rs | Implements guardrail pipeline (OpenAI Moderation) and SSE hold-and-release stream guarding. |
| inference-router/src/failover.rs | Updates test helpers for expanded UpstreamConfig. |
| inference-router/src/config.rs | Adds router config surface for Anthropic/Ollama endpoints and Moderation backend config + secret mount loading. |
| docs/architecture.md | Updates architecture notes to include Anthropic/Ollama as wired providers. |
| docs/api/crd-reference.md | Documents spec.provider and spec.guardrails[] and clarifies precedence/semantics. |
| deploy/helm/kars/templates/crd-inferencepolicy.yaml | Regenerates CRD schema to include provider/guardrails fields + validations. |
| controller/src/reconciler/mod.rs | Forwards router-only env vars for provider endpoints/keys and moderation backend to sidecars (skipping empty). |
| controller/src/inference_policy.rs | Adds typed InferenceProvider + guardrail stage types to the CRD model. |
| controller/src/inference_policy_reconciler.rs | Enforces bundleRef mutual exclusion with provider/guardrails and keeps bundle canonical format unchanged. |
| controller/src/inference_policy_compile.rs | Emits compiled profile JSON keys provider and guardrails (null when absent) with wire-tag pin tests. |
| controller/src/crd_validations.rs | Adds CEL validations for bundleRef exclusivity and guardrail stage count bounds (1–8). |
| controller/src/config_hash.rs | Adds provider/guardrail endpoint env vars (non-secret) to config hash inputs. |
| CHANGELOG.md | Documents the multi-provider + guardrails slice and associated behavior/constraints. |
@microsoft-github-policy-service agree |
Address Copilot review on Azure#488: - Responses-API recovery SSE branch now emits the outcome-accurate frame (content_policy_violation vs guardrail_unavailable/ guardrail_misconfigured) instead of a hard-coded violation frame — scan_openai_output_guardrails returns the block as data and each transport picks its wire shape. - SseGuardState trims retained scan context to MAX_SCAN_CHARS after every clean scan; output scans only ever submit the trailing MAX_SCAN_CHARS anyway, so per-connection memory stays bounded on long streams without weakening scanned-before-delivery. Regression test added.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.
Suppressed comments (5)
inference-router/src/guardrails.rs:731
- If an SSE
data:payload is not valid JSON (or doesn't match the expected dialect),unscannedstays at 0 and the code may treat the chunk as safe to release. That breaks the “no model text reaches the client before a scan has covered it” contract and makes the guard bypassable by non-JSON/variant frames. A safe fallback is to treat unparseabledata:payloads as text-bearing and include them in the scan context (or fail closed).
if let Ok(event) = serde_json::from_str::<serde_json::Value>(payload)
&& let Some(text) = delta_text_from_event(self.dialect, &event)
{
self.unscanned += text.chars().count();
self.accumulated.push_str(&text);
}
inference-router/src/guardrails.rs:745
- In
on_chunk, whenunscanned == 0the code immediately releases all held bytes. This is unsafe whenline_carrycontains a partialdata:line split across chunks: the first chunk can contain unscanned model output bytes but no newline yet, so it gets released before the JSON payload is parsed and scanned. Only release immediately when there is no partial line carry buffered.
// Fast path: window not full. Chunks carrying no delta
// text at all (keepalives, role/annotation frames) are
// safe to release immediately when nothing text-bearing
// is being held alongside them.
if self.unscanned == 0 {
return SseGuardStep::Release(std::mem::take(&mut self.held));
}
inference-router/src/routes/anthropic_messages.rs:287
- PR description claims
x-kars-decision*headers are attached on every block. The Anthropic route returnsdeny_response(...)for provider-resolution and guardrail failures, but this helper currently doesn’t inject the decision headers (unlike the/v1/chat/completionspath). That’s observable API drift for clients relying on those headers for auditing/telemetry.
// Multi-provider slice: retarget at the policy-selected provider.
// Fails closed — see `routes::apply_provider_resolution`.
if let Err(e) = crate::routes::apply_provider_resolution(&state, &mut upstream, &policy) {
tracing::warn!(
target: "inference.audit",
sandbox = %sandbox_name,
inference_policy_digest = %policy.digest,
decision = "deny",
gate = "provider_resolution",
error = %e,
"InferencePolicy provider could not be resolved (anthropic route)"
);
let status = match e {
ProviderError::Unimplemented { .. } => StatusCode::NOT_IMPLEMENTED,
_ => StatusCode::SERVICE_UNAVAILABLE,
};
return deny_response(status, &e.to_string(), "api_error");
}
docs/api/crd-reference.md:495
- Docs currently state
spec.bundleRefis an alternative to inlineprovider/guardrails, but the controller-side reconciler explicitly notes the signed-bundle canonical format does not carryprovider/guardrailsyet (they’re forced toNonewhen a bundle is used). As written, this suggests operators can move these fields into bundles when they currently cannot.
| `spec.bundleRef` | Signed OCI artifact alternative to inline `tokenBudget` / `contentSafety` / `modelPreference` / `provider` / `guardrails` / `displayName`. `appliesTo` always comes from the CR. |
deploy/helm/kars/templates/crd-inferencepolicy.yaml:198
- The CRD schema text says guardrail stages are materialized “at policy load time”, but the router builds the
GuardrailPipelineper request from the policy snapshot (build_guardrail_pipelineinroutes/chat_completions.rs). This description looks stale/misleading for operators troubleshooting runtime failures (which happen at request time).
A single stage of the router-side guardrail pipeline. The router
materialises each stage into a scanner (network client + policy)
at policy load time; a stage whose backend is not configured on
the router (e.g. missing moderation API key) fails the *request*
Second Copilot review round on Azure#488: - SSE guard accepts 'data:' with or without whitespace, so spaceless events can't slip through the stream scan unrecognised. - New guardrails::scan_text_or_raw: when a declared input/output guardrail is active and the body fails to parse as JSON, scan the raw (lossy-UTF-8) bytes instead of skipping — applied to the chat-completions input scan, buffered output scan, and the Anthropic pass-through buffered output scan.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (6)
inference-router/src/proxy.rs:527
- Same as the buffered forward path: this wraps provider-specific credential resolution, not just token acquisition. The current message is misleading for Anthropic/Ollama.
let credential = credential_for_upstream(&auth, copilot.as_deref(), &upstream)
.await
.context("Failed to acquire auth token")?;
let headers = build_upstream_headers(&request_headers, &auth, &credential, &upstream.endpoint)?;
inference-router/src/routes/chat_completions.rs:387
- The explicit 501 returned when a policy selects the Anthropic provider doesn’t attach the canonical
x-kars-decision*headers, unlike other provider/guardrail blocks in this handler. This makes denial telemetry inconsistent for downstream tooling that relies on these headers.
if upstream.provider == ProviderKind::Anthropic {
return errors::openai(
StatusCode::NOT_IMPLEMENTED,
"InferencePolicy selects provider 'anthropic', which serves the Anthropic \
Messages API — send Anthropic-shaped requests to /anthropic/v1/messages \
(chat-completions translation for Anthropic is not implemented)",
"provider_unimplemented",
)
.into_response();
inference-router/src/proxy.rs:300
- This error context is now used for all provider types (including Anthropic API keys and unauthenticated Ollama), so the message “auth token” is misleading. Renaming it to “upstream credential” makes logs/errors accurate across providers.
This issue also appears on line 524 of the same file.
let credential = credential_for_upstream(auth, copilot, upstream)
.await
.context("Failed to acquire auth token")?;
controller/src/crd_validations.rs:283
- The validation message claims “the bundle carries those content fields”, but this repo currently explicitly drops
provider/guardrailswhenbundleRefis used (seemerge_bundle_with_selectorin this same PR). The message should not imply the bundle containsprovider/guardrailsyet.
ValidationRule {
rule: "!has(self.bundleRef) || (!has(self.tokenBudget) && !has(self.contentSafety) && !has(self.modelPreference) && !has(self.provider) && !has(self.guardrails) && !has(self.displayName))".into(),
message: Some("spec.bundleRef is mutually exclusive with spec.tokenBudget, spec.contentSafety, spec.modelPreference, spec.provider, spec.guardrails, and spec.displayName; the bundle carries those content fields".into()),
reason: Some("FieldValueInvalid".into()),
..ValidationRule::default()
deploy/helm/kars/templates/crd-inferencepolicy.yaml:331
- This CRD CEL validation message also claims “the bundle carries those content fields”, but bundle-sourced policies currently do not carry
provider/guardrailsyet (they are forced to defaults). The message should avoid implying otherwise.
- message: spec.bundleRef is mutually exclusive with spec.tokenBudget, spec.contentSafety, spec.modelPreference, spec.provider, spec.guardrails, and spec.displayName; the bundle carries those content fields
reason: FieldValueInvalid
rule: '!has(self.bundleRef) || (!has(self.tokenBudget) && !has(self.contentSafety) && !has(self.modelPreference) && !has(self.provider) && !has(self.guardrails) && !has(self.displayName))'
docs/api/crd-reference.md:495
- The docs currently describe
bundleRefas an alternative to inlineprovider/guardrails, which implies those fields can come from the signed bundle. In this PR they’re explicitly inline-only and mutually exclusive withbundleRef, and bundle-sourced policies drop them to defaults. Updating this line would prevent operator confusion.
| `spec.bundleRef` | Signed OCI artifact alternative to inline `tokenBudget` / `contentSafety` / `modelPreference` / `provider` / `guardrails` / `displayName`. `appliesTo` always comes from the CR. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (3)
inference-router/src/proxy.rs:526
- Same as above: this path resolves provider-specific credentials (including unauthenticated Ollama), so “auth token” is no longer an accurate description of what failed.
let credential = credential_for_upstream(&auth, copilot.as_deref(), &upstream)
.await
.context("Failed to acquire auth token")?;
inference-router/src/guardrails.rs:725
SseGuardState::ingest_textdecodes arbitrary upstream bytes viaString::from_utf8_lossy(chunk)and appends intoline_carry. If a multi-byte UTF-8 sequence is split across chunk boundaries,from_utf8_lossycan replace it with U+FFFD, so the guardrail scans a different text than what the client ultimately receives. This can weaken the “no unscanned text is released” guarantee for non-ASCII output.
Consider buffering as raw bytes and only decoding complete lines (split on \n) with std::str::from_utf8, failing closed (cut the stream with a guardrail error frame) on invalid UTF-8 / unparseable data: payloads.
fn ingest_text(&mut self, chunk: &[u8]) {
self.line_carry.push_str(&String::from_utf8_lossy(chunk));
// Keep the trailing partial line (no '\n' yet) in the carry.
inference-router/src/proxy.rs:299
- The error context string is now misleading:
credential_for_upstreamcan return an Anthropic API key orNonefor Ollama, not just an auth token. Using “auth token” in the context makes failures harder to interpret when debugging non-Azure providers.
This issue also appears on line 524 of the same file.
let credential = credential_for_upstream(auth, copilot, upstream)
.await
.context("Failed to acquire auth token")?;
…ning Independent review of Azure#488 found four blockers; all fixed with tests and verified live against real Anthropic/Ollama + a moderation stub. B1 — SSE hold-and-release leaked unscanned model text on a chunk boundary that fell inside a data: line: the partial line's bytes were already in `held` but its delta text was uncounted, and the unscanned==0 fast-release shipped them. Now never release while a partial line is buffered; on_end flushes a trailing unterminated line before the final scan. Regression test splits an event mid-content; live test with a 40-char threshold delivered only the error frame. B2 — buffered Responses-API recovery path (400 'unsupported' → /responses) returned the completion with no output scan. Added the enforce_openai_output_guardrails call, matching the other two recovery paths. B3 — routing was retroactively driven by the pre-existing modelPreference.primary.provider tag, so an unchanged CR could 503 or silently cross clouds on upgrade. spec.provider is now the sole routing selector; modelPreference.provider stays informational (drives deployment failover only). Verified: modelPreference provider=anthropic with no spec.provider stays on the Azure upstream. B4 — the sibling inference routes (/v1/completions, /v1/responses, /v1/embeddings, image generation) didn't consult provider/guardrail policy, so an agent could bypass both by not using chat/completions. They now fail closed: 501 on a non-Azure spec.provider, 403 when guardrails are declared. Pure classifier unit-tested; live-verified 501 on /v1/embeddings and /v1/responses under an ollama policy. M1 — 16k scan cap truncated instead of windowing, letting content hide past the cap; GUARDRAIL_STREAM_SCAN_CHARS was unclamped. Now scan_windows() covers all text in successive MAX_SCAN_CHARS windows and the stream threshold is clamped to the cap. Docs/CHANGELOG corrected: routing precedence, sibling-route scope, windowed scanning, and the tool-arg / thinking-delta output-scan gaps (roadmap). 1958 tests green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
controller/src/inference_policy.rs:166
- The doc comment claims
modelPreference.primary.provider“takes precedence” overspec.provider, but the router implementation and docs statespec.provideris the only routing selector andmodelPreference.*.providerstays informational. This comment is misleading and suggests behavior that does not exist.
/// process. `modelPreference.primary.provider`, when it names a
/// recognised tag, takes precedence over this field so a fallback
/// chain can pin its own route. Mutually exclusive with
Review comment on Azure#488: SseGuardState::ingest_line ignored data: payloads that don't parse as JSON, so unscanned stayed 0 and the fast-release path shipped those bytes without a scan — an upstream drift / malformed frame could bypass the scanned-before-delivery contract. Non-JSON data payloads are now added to the scan buffer as raw text. Valid-JSON structural frames (ping / role-only / stop) still release without a scan round-trip; the non-content extraction surface (tool-call args, thinking deltas) remains the documented follow-up. Two regression tests added.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (3)
controller/src/inference_policy.rs:167
- The doc comment says
modelPreference.primary.providertakes precedence overspec.provider, but the router code explicitly ignoresmodelPreference.*.providerfor routing (seeinference-router/src/routes/mod.rswhereapply_model_preference_overrideignoresprimary.providerand provider routing is driven bypolicy.provider). This comment is misleading for CRD/API consumers and contradicts the fail-closed routing semantics described elsewhere in the PR.
/// process. `modelPreference.primary.provider`, when it names a
/// recognised tag, takes precedence over this field so a fallback
/// chain can pin its own route. Mutually exclusive with
inference-router/src/proxy.rs:118
- The comment implies an inbound SDK-provided auth value is copied through, but
build_upstream_headersexplicitly strips inboundx-api-key(and other creds) before injecting provider auth. Clarifying that onlyanthropic-versionis preserved (whilex-api-keyis always replaced) makes the security behavior unambiguous.
// Anthropic's Messages API authenticates with `x-api-key` and
// requires an `anthropic-version` header. The inbound SDK
// value (when present) was already copied through above —
// only the default is filled in here.
controller/src/reconciler/mod.rs:1964
- The router supports
OPENAI_MODERATION_MODELandGUARDRAIL_STREAM_SCAN_CHARSenv overrides (seeinference-router/src/config.rsandinference-router/src/guardrails.rs), but the controller only forwards moderation key/endpoint into the router sidecar env. In controller-managed sandboxes this makes those overrides effectively unusable, despite being documented/tunable in the router layer.
// Multi-provider inference + guardrail backends. Router-only —
// never on the agent container. Empty values are skipped so
// Azure-only clusters keep an identical env surface (and
// config-hash) to previous releases.
for (name, value) in [
("ANTHROPIC_API_KEY", &ctx.anthropic_api_key),
("ANTHROPIC_ENDPOINT", &ctx.anthropic_endpoint),
("OLLAMA_ENDPOINT", &ctx.ollama_endpoint),
("OPENAI_MODERATION_API_KEY", &ctx.openai_moderation_api_key),
(
"OPENAI_MODERATION_ENDPOINT",
&ctx.openai_moderation_endpoint,
),
] {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (3)
inference-router/src/guardrails.rs:414
scan_windowsis documented as splitting by characters, but the early-return check usestext.len()(bytes). For non-ASCII text this can enter the slow path unnecessarily and makes the implementation diverge from the stated contract. Use a char-count check for consistency with the rest of the function.
fn scan_windows(text: &str) -> Vec<&str> {
if text.len() <= MAX_SCAN_CHARS {
return vec![text];
}
inference-router/src/routes/chat_completions.rs:249
- The error
codeused forRouteGap::NonAzureProviderisprovider_unimplemented, but the message indicates the provider is implemented and only this route lacks enforcement. Using a distinct code (e.g.provider_route_unsupported) avoids conflating “provider not implemented by this router build” (bedrock) with “implemented elsewhere but unsupported on this endpoint”.
RouteGap::NonAzureProvider => (
StatusCode::NOT_IMPLEMENTED,
"provider_unimplemented",
format!(
inference-router/src/config.rs:108
- The doc comment for
openai_moderation_api_keysays it “falls back toOPENAI_API_KEY, then theopenai-moderation-api-keysecret mount”, but the implementation checks theopenai-moderation-api-keymount (viasecret_from_env_or_mount) before falling back toOPENAI_API_KEY. Updating the comment avoids confusing operators about precedence.
/// OpenAI Moderation key — `OPENAI_MODERATION_API_KEY` env (falls
/// back to `OPENAI_API_KEY`, then the `openai-moderation-api-key`
/// secret mount). `None` ⇒ `openai-moderation` stages fail closed.
|
thanks for the thorough security pass Pal Lakatos-Toth (@pallakatos). confirmed that this was a real bypass. foundry_proxy now runs the same fail-closed guard_unenforced_route used by the sibling /v1/* routes as its first check for the inference-bearing path families (/agents*, /openai/responses*, /openai/conversations*, whole-segment match). the taxonomy is identical to the existing guarded routes: 403 guardrail_route_unsupported when the policy declares guardrails, 501 provider_unimplemented when it selects a non-Azure provider, with the x-kars-decision* headers and route_enforcement_gap audit line attached. ready for the functional/compatibility review whenever you are |
|
Thanks for the fast follow-up. I re-reviewed HIGH — raw-path guard bypass via dot-segment normalization
Verified examples: These route through an unguarded wildcard, then normalize upstream to Please canonicalize once before classification and use that same canonical value for forwarding, or reject dot-segment / percent-encoded-dot paths at the start of Please add real-router tests for literal Also, no build/test/Clippy/CodeQL workflow has run on the new head yet—only CLA is present. The PR remains blocked pending this fix and full CI. |
|
Additional functional/CI review findings from independent execution and the now-approved GitHub workflows: Independent local validation
GitHub CI blockers
These are in addition to the unresolved raw-path/dot-segment guardrail bypass in the previous comment. The PR remains unapproved pending all four items and the continuing full functional logic review. |
|
Full functional/compatibility review is now complete. The architecture and default compatibility path are generally sound: when In addition to the previously posted traversal and CI blockers, these functional defects need resolution: HIGH — buffered chat output skips guardrails when upstream body is not JSON
MEDIUM — Anthropic streaming never records token usageThe buffered Anthropic passthrough records usage, but the MEDIUM — SSE moderation text is corrupted when UTF-8 code points cross chunk boundaries
MEDIUM — malformed compiled guardrail config silently disables enforcement
LOW — streaming scans only
|
…ipeline Roadmap slice 1 of 'multi-cloud LLM providers + native guardrails': Anthropic + Ollama providers and an OpenAI Moderation guardrail stage, policy-driven via InferencePolicy. Provider credentials stay on the router sidecar (secret mount / env) — the agent process never sees them. Controller: - InferencePolicy spec.provider (typed InferenceProvider enum with kebab-case wire tags matching the existing ModelRef.provider strings) and spec.guardrails[] (openai-moderation, applyTo input|output|both). - Compile step emits 'provider' + 'guardrails' in the compiled profile; CEL + reconciler keep both mutually exclusive with bundleRef. - Reconciler forwards ANTHROPIC_API_KEY/ANTHROPIC_ENDPOINT/ OLLAMA_ENDPOINT/OPENAI_MODERATION_* to router sidecars when set; helm CRD template regenerated. Router: - New provider module: fail-closed resolution (unimplemented bedrock -> 501, missing endpoint/credential -> 503; never a silent Azure fallback). Provider-aware UpstreamConfig, URL shapes and auth schemes (Anthropic x-api-key + anthropic-version; Ollama unauthenticated OpenAI-compat under /v1/). - Anthropic Messages native pass-through (streaming + tool use) on /anthropic/v1/messages; Ollama chat completions buffered + SSE. - New guardrails module: Guardrail trait + OpenAI Moderation backend; input pre-flight, buffered output, and hold-and-release SSE scanning (no model text delivered before a scan covers it). Fail-closed on misconfigured stages and backend outages; kars_guardrail_scans_total metric. Tests: 857 controller + 997 router unit tests green; new wiremock integration suite (fake Anthropic/Ollama/moderation upstreams).
Address Copilot review on Azure#488: - Responses-API recovery SSE branch now emits the outcome-accurate frame (content_policy_violation vs guardrail_unavailable/ guardrail_misconfigured) instead of a hard-coded violation frame — scan_openai_output_guardrails returns the block as data and each transport picks its wire shape. - SseGuardState trims retained scan context to MAX_SCAN_CHARS after every clean scan; output scans only ever submit the trailing MAX_SCAN_CHARS anyway, so per-connection memory stays bounded on long streams without weakening scanned-before-delivery. Regression test added.
Second Copilot review round on Azure#488: - SSE guard accepts 'data:' with or without whitespace, so spaceless events can't slip through the stream scan unrecognised. - New guardrails::scan_text_or_raw: when a declared input/output guardrail is active and the body fails to parse as JSON, scan the raw (lossy-UTF-8) bytes instead of skipping — applied to the chat-completions input scan, buffered output scan, and the Anthropic pass-through buffered output scan.
…esponses Found in live testing against api.anthropic.com: when the upstream connection negotiates HTTP/1.1, Anthropic responds with transfer-encoding: chunked. The pass-through handler copied all upstream headers onto the rebuilt axum response, and hyper refuses to serialize a response carrying a stale framing header — the client got an empty reply despite a 200 from upstream. The pre-existing Copilot pass-through never hit this (h2 end-to-end), and wiremock tests don't (simple headers). Both relay loops (buffered + streaming) now skip the RFC 9110 connection-specific headers; hyper re-frames the body itself. Verified live: non-streaming + SSE Messages against api.anthropic.com, chat completions (buffered + SSE) against local Ollama, policy hot-reload provider swap, guardrail fail-closed without a key (503), and input-block + mid-stream output cut against a local moderation stub.
…ning Independent review of Azure#488 found four blockers; all fixed with tests and verified live against real Anthropic/Ollama + a moderation stub. B1 — SSE hold-and-release leaked unscanned model text on a chunk boundary that fell inside a data: line: the partial line's bytes were already in `held` but its delta text was uncounted, and the unscanned==0 fast-release shipped them. Now never release while a partial line is buffered; on_end flushes a trailing unterminated line before the final scan. Regression test splits an event mid-content; live test with a 40-char threshold delivered only the error frame. B2 — buffered Responses-API recovery path (400 'unsupported' → /responses) returned the completion with no output scan. Added the enforce_openai_output_guardrails call, matching the other two recovery paths. B3 — routing was retroactively driven by the pre-existing modelPreference.primary.provider tag, so an unchanged CR could 503 or silently cross clouds on upgrade. spec.provider is now the sole routing selector; modelPreference.provider stays informational (drives deployment failover only). Verified: modelPreference provider=anthropic with no spec.provider stays on the Azure upstream. B4 — the sibling inference routes (/v1/completions, /v1/responses, /v1/embeddings, image generation) didn't consult provider/guardrail policy, so an agent could bypass both by not using chat/completions. They now fail closed: 501 on a non-Azure spec.provider, 403 when guardrails are declared. Pure classifier unit-tested; live-verified 501 on /v1/embeddings and /v1/responses under an ollama policy. M1 — 16k scan cap truncated instead of windowing, letting content hide past the cap; GUARDRAIL_STREAM_SCAN_CHARS was unclamped. Now scan_windows() covers all text in successive MAX_SCAN_CHARS windows and the stream threshold is clamped to the cap. Docs/CHANGELOG corrected: routing precedence, sibling-route scope, windowed scanning, and the tool-arg / thinking-delta output-scan gaps (roadmap). 1958 tests green.
Review comment on Azure#488: SseGuardState::ingest_line ignored data: payloads that don't parse as JSON, so unscanned stayed 0 and the fast-release path shipped those bytes without a scan — an upstream drift / malformed frame could bypass the scanned-before-delivery contract. Non-JSON data payloads are now added to the scan buffer as raw text. Valid-JSON structural frames (ping / role-only / stop) still release without a scan round-trip; the non-content extraction surface (tool-call args, thinking deltas) remains the documented follow-up. Two regression tests added.
Condense the narrative doc/inline comments added across this slice (guardrails, provider, chat_completions, anthropic_messages, config, loader, and the controller CRD types) down to the constraints the code can't show. Also corrects the stale spec.provider field doc — it's the sole routing selector now, modelPreference.provider is informational. Helm CRD regenerated for the trimmed schema descriptions; behaviour unchanged, 1960 tests green.
The fail-closed route guard covered /v1/completions, /v1/responses, embeddings, and image generation, but not the Foundry proxy families that serve model output: /agents*, /openai/responses*, and /openai/conversations*. An agent blocked by a guardrail on /v1/chat/completions could rerun the same inference through /openai/responses and receive an unscanned response. foundry_proxy now runs guard_unenforced_route first for these path families (whole-segment match, no lookalike false positives) with the same taxonomy as the sibling routes: 403 guardrail_route_unsupported when guardrails are declared, 501 provider_unimplemented for a non-Azure provider, decision headers + route_enforcement_gap audit line included. Non-inference Foundry surfaces (memory stores, files, vector stores, evaluations, ...) are management/storage APIs, not inference channels, and stay unguarded; enforcement scope is now documented in docs/api/crd-reference.md and the CHANGELOG. Tests: unit tests for the path classifier; new tests/foundry_route_guard.rs exercises the real Router::merge wiring from main.rs — guardrail policy 403s all three families, anthropic provider 501s, and control tests prove unaffected requests still reach the proxy body.
Move build_pod_security_context, isolation_scheduling, and build_egress_guard_command (plus their tests) out of reconciler/mod.rs into reconciler/pod_spec.rs, re-exported via pub(crate) use. Brings reconciler/mod.rs back under the 3700-line CI cap. Pure move, no behavior change.
Correctness:
- from_compiled_json now returns Result; a present-but-malformed
guardrails block poisons the policy so every request fails closed
instead of silently degrading to "no guardrails" (which also
disarmed the sibling route-gap guard). The loader installs an
unbuildable sentinel stage on parse error.
- SSE moderation reconstructs multi-byte UTF-8 code points split
across chunk boundaries, so the scanner sees the same text the
client receives (no U+FFFD divergence).
- Streaming delta extraction scans every choices[] entry, not just
choices[0].
Refactor:
- Split guardrails.rs (>1600 lines) into guardrails/{mod,backend,
stream,tests}.rs, each under the 800-line CI cap. Public API is
unchanged (re-exported); a few items raised to pub(crate) so the
test module can construct and inspect them.
Regression tests for the UTF-8 split (flagged and benign),
multi-choice extraction, and malformed-config fail-closed.
…age, unified error codes Security / fail-closed: - foundry_proxy percent-decodes each path segment and rejects (400 invalid_path) any dot / empty / encoded-slash segment before classification or forwarding, closing a traversal bypass where /openai/files/../responses normalized upstream into a guarded inference route. The guard is now default-deny: everything is guarded except an explicit exempt set of management/storage APIs. - Buffered output guardrail enforcement is hoisted out of the JSON-parse block so a non-JSON/truncated upstream body is still scanned via the raw-text fallback rather than returned verbatim. Fixes: - Anthropic streaming passthrough records token usage (input from message_start, output from the latest message_delta), closing a budget-accounting gap; multi-byte UTF-8 is carried across chunk boundaries like the guardrail stream. - Provider/guardrail denials carry a stable error.code across chat-completions, the sibling routes, and the Anthropic route via a shared errors::openai_coded helper. CI: - Box the output-guardrail error response (clippy result_large_err under -D warnings) and collapse an else-if block. Tests: traversal matrix + reqwest::Url normalization premise, default-deny lookalikes, non-JSON buffered output scan, Anthropic streaming usage.
- Add docs/security-audits/2026-08-25-multi-provider-guardrails.md (T1/T2/T3 triage + sign-offs) required by the security-audit CI gate for capability-path changes. - Document the default-deny Foundry proxy guard and path-rejection behavior in the CRD reference and CHANGELOG.
2d1973c to
708b240
Compare
|
thanks for the second pass Pal Lakatos-Toth (@pallakatos). All items addressed Traversal bypass (HIGH): foundry_proxy now percent-decodes each path segment and rejects (400 invalid_path) any dot/empty/encoded-slash segment before classification or forwarding, so a path can't normalize into a different route than the one classified. Guard is now default-deny (explicit exempt set for management/storage families). Added your full matrix plus a reqwest::Url normalization premise test. CI blockers: result_large_err boxed (clippy -D warnings clean); guardrails.rs split into guardrails/{mod,backend,stream,tests}.rs and reconciler/pod_spec.rs extracted, all under the caps; security-audit doc added. Rebase not done yet: the result_large_err fix landed on main, so I'll rebase carefully as a separate step to handle the chat_completions.rs conflict. Functional defects: non-JSON buffered output now scanned (enforcement hoisted out of the JSON-parse block); Anthropic streaming records usage; SSE UTF-8 reconstructed across chunk boundaries; malformed guardrail config fails closed (poisons the policy instead of degrading to "no guardrails"); streaming scans all choices[]; denials carry a stable error.code. Local: cargo test --workspace 2201 passed, clippy/fmt clean, all ci/*.sh gates pass. Also smoke-tested against real Ollama and real Anthropic: buffered + streaming both return real completions,1and an agent-supplied x-api-key is confirmed stripped and replaced (real 200, not 401). |
|
Security re-review of One process/security-control blocker remains in Please remove the maintainer sign-off you added and leave only your own author sign-off. After the independent functional re-review and final CI are complete, I will add/authorize the independent reviewer sign-off through a maintainer-owned commit or explicit approval. Please also correct the audit statement claiming the existing blocklist/egress guard applies to Anthropic/Ollama provider calls; those routes do not currently invoke |
|
Functional re-review of Three remaining functional/compatibility items need resolution before approval: MEDIUM — Anthropic streaming usage is lost when a guardrail cuts mid-stream
Please record from the guard terminal path or share usage state with an outer wrapper, and add a test that drains the guard through a mid-stream cut and asserts exactly-once input/output accounting. MEDIUM — Anthropic buffered error codes are still inconsistentThe same guardrail outcome differs by route/transport:
This defeats the stated stable COMPATIBILITY — trailing/double-slash paths now fail even with no policy
Lower-severity cleanup: one chat denial ( The contributor-authored maintainer sign-off remains a separate blocker from the previous comment. All current code/tests/gates are otherwise clean in independent execution. |
…ded errors + decision headers Streaming usage (MEDIUM): the usage tap sat inside guard_sse_stream, so a mid-stream guardrail cut dropped it before its terminal ran and budget stayed zero despite consumed tokens. Split into an observer (inside the guard, updates shared usage) and a finalizer (outside the guard, records once on any terminal: cut, error, or clean end). Error contract (MEDIUM + cleanup): buffered Anthropic denials now carry the stable machine code (guardrail_blocked / guardrail_misconfigured / guardrail_unavailable / provider_*) in error.code while keeping the Anthropic-native error.type, via a new deny_policy helper that also attaches the x-kars-decision* headers. The chat provider:anthropic 501 now uses the coded helper + decision headers too. Tests: mid-stream-cut accounting (input billed once through a real guard cut), buffered coded-error shape, and decision-header presence only on policy denials.
…jecting COMPATIBILITY: decoded_path_segments rejected empty segments outright, so historically proxied management paths (POST /openai/files/f1/, /memory_stores/s1/, /openai/files//f1) returned 400 even with no policy configured. Collapse benign empty segments to canonical form while still rejecting dot segments (., .., percent-encoded), smuggled slashes/backslashes, and malformed escapes. Adds no-policy compat tests across guardrails on/off.
- Remove the maintainer sign-off added by the contributor; leave only the author sign-off. The independent reviewer adds theirs via a maintainer-owned commit. - Correct the T1 claim: the Anthropic/Ollama provider forward paths do not invoke the blocklist (is_blocked); destinations are operator-controlled but not blocklist-checked.
|
Final functional re-review found one remaining blocker in the actual native Anthropic buffered path, plus documentation/message corrections. The second audit sign-off cannot be added yet. HIGH — buffered native Anthropic passthrough still uses legacy error codes and no decision headers
The translated path and streaming native path now emit the stable specific codes, so the identical request differs solely by Please route both branches through Documentation and runtime message mismatch
Everything else in this delta is verified clean: mid-stream usage records once through the composed observer→guard→finalizer path; traversal remains closed; chat Anthropic denial aligns; supply-chain review is clean; 2,206 tests and technical CI pass. The audit sign-off remains pending these final corrections. |
…ed errors + decision headers forward_anthropic_passthrough's native buffered output branches still used the legacy deny_response (content_policy_violation / generic api_error with a hard-coded 502, no decision headers), so an identical request returned the stable codes when streamed but legacy/generic codes when buffered. Route both branches through deny_policy: v.code() for violations, guardrail_error_status(&e) + e.code() for errors, matching the translated and streaming paths. This was the last guardrail/provider denial site not on the coded contract. Adds route-level tests that drive the real native buffered /v1/messages handler (wiremock Anthropic upstream + moderation): a flagged output gives 403 guardrail_blocked and a moderation outage gives 502 guardrail_unavailable, both with x-kars-decision headers.
…s collapsed) decoded_path_segments now collapses benign empty segments (//, trailing /) rather than rejecting them, but five descriptions still said empty segments are rejected. Align all of them — the audit-log message and client 400 text (inference.rs), the security audit doc, the CRD reference, the CHANGELOG, and a test comment — to state that empty segments are canonicalized while dot (literal/percent-encoded), encoded slash/backslash, and malformed escapes are rejected.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee78c86b-0001-4d24-b829-7c75b59c9316
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee78c86b-0001-4d24-b829-7c75b59c9316
Pal Lakatos-Toth (pallakatos)
left a comment
There was a problem hiding this comment.
Approved after final independent review of f3200672.
Supply chain and provenance: reviewed all 17 contributor commits as hostile input. The contribution adds no dependency/lockfile, workflow, build/release, Dockerfile, vendored, binary, generated-artifact, custom-crypto, or unsafe-code surface. Network destinations and provider credentials remain router/operator controlled; no request-controlled SSRF or credential-forwarding path was found. Contributor commits and authorship remain intact; current main was merged without rewriting history.
Security and compatibility: all previously reported route-coverage, path-normalization, malformed-policy, non-JSON output, streaming UTF-8/multi-choice, Anthropic accounting, stable-error, decision-header, and audit-control issues are fixed and exercised through real route-level regressions. Default Azure behavior with no provider/guardrail policy remains unchanged, while vulnerable bypass paths fail closed.
Independent execution: formatting and Clippy pass; 18 focused route tests pass; the full Rust suite passes with 2,208 tests and zero failures; all seven technical gates pass. The full remote matrix is green, including Rust/CLI/runtime/mesh tests, E2E, chaos, benchmarks, CodeQL analyses, dependency review, RustSec, cargo-deny, Trivy, secret scanning, image scanning, and the independent security-audit gate.
No high-confidence security, supply-chain, functional, or live-customer compatibility blockers remain.
Summary
This is a first slice of the multi-cloud provider + guardrails roadmap theme. It lets an
InferencePolicypick the inference provider and declare a guardrail pipeline, and teaches the router to actually honor both:I started with Anthropic and Ollama because they're the two cheapest to do properly: Anthropic gets native Messages pass-through on
/anthropic/v1/messages(streaming, tool use and multi-modal all survive, which the existing translation route couldn't offer), and Ollama is OpenAI-compatible so chat completions just work, buffered and SSE, with token metering intact.The part I'd most like eyes on is the streaming guardrail. Scanning a stream after the fact is theater, so the SSE guard holds chunks back and only releases a window once a scan has covered it (default 1000 chars per window,
GUARDRAIL_STREAM_SCAN_CHARSto tune). Flagged streams get cut with a structured error frame. The trade-off is chunkier delivery when a pipeline is active; policies without guardrails don't pay anything.Everything is fail-closed on purpose. A policy that names a provider the router can't serve gets a 501 (
bedrock) or 503 (missing key/endpoint) instead of quietly falling back to Azure, and a declared guardrail stage that can't run blocks the request rather than becoming an open gate. Provider keys (ANTHROPIC_API_KEY,OPENAI_MODERATION_API_KEY) only ever exist on the router sidecar - the reconciler forwards them from controller env the same way the dev creds already travel, and the router strips/replaces anyx-api-keyan agent tries to smuggle through (there's an integration test for exactly that).Helm CRD template regenerated via the drift-test dumper. Endpoints (not secrets) joined
CONFIG_HASH_INPUTS. Both Copilot review comments are addressed in4b48b87c(outcome-accurate SSE error frames; bounded stream scan context).Related Issues
No existing issue tracks this - the provider-expansion note in
docs/architecture.mdasks for a feature request per provider, so I'm happy to file one (auth model, Foundry-feature preservation, the works) and link it here if that's the flow you want. Follow-up slices (Bedrock, Vertex, vLLM; Bedrock Guardrails, Model Armor) would extend the same two seams:provider::ProviderKindand theguardrails::Guardrailtrait.Type of Change
Checklist
cargo check --workspace --tests)collapsible_ifinroutes/mod.rsalso fires onmainwith this toolchain)docs/api/crd-reference.md,docs/architecture.md,CHANGELOG.md)crd-inferencepolicy.yamlregenerated)Testing
Automated (all offline):
inference-router/tests/multi_provider_guardrails.rs): fake Ollama (URL shape, no credentials sent), fake Anthropic (router key replaces agent-supplied key,anthropic-versioninjected), fake moderation endpoint (flag/pass/500→fail-closed), and missing-key pipeline construction failure.Live, running the router binary locally:
api.anthropic.com, non-streaming and SSE, plus the 501 onchat/completionsunder an Anthropic policy.guardrail_misconfigured), input blocked pre-flight (403), and a mid-stream output cut - clean prompt, model emits the flagged word, zero bytes of model text reach the client, just the structured error frame +[DONE].kars_guardrail_scans_totalcounted every scan by direction/outcome. The moderation verdict itself came from a local stub (no OpenAI key on this machine); everything around it was real.Enforcement edge cases the tests + live run cover explicitly:
data:line, and scans non-JSONdata:payloads as raw text rather than fast-releasing an unrecognised frame. A 40-char-threshold live run delivered only the error frame on a cut, zero content deltas.spec.provideris the sole routing selector; the pre-existingmodelPreference.primary.providertag stays informational, so an unchanged CR that only set a model preference keeps its Azure upstream (verified live)./v1/completions,/v1/responses,/v1/embeddings, image generation) don't implement provider routing or guardrails, so they fail closed (501 on a non-Azure provider, 403 when guardrails are declared) rather than silently bypass the policy and live-verified 501 on/v1/embeddingsand/v1/responses. Text over 16k chars is scanned in successive windows, not truncated.