Skip to main content

av_harness/
pipeline.rs

1//! Ordered hot-path middleware and upstream forwarding.
2
3use crate::config::HarnessConfig;
4use crate::reconciler::Finalizer;
5use crate::session::{Session, SessionLease, SessionRegistry, Workflow};
6use crate::worker::{AtifCapture, ResponsePermit, WorkerHandle, WorkerJob};
7use av_bridge::EventBus;
8use av_core::metrics::Registry;
9use av_core::time::elapsed_us;
10use av_events::{AgentIdentity, EventClass, EventMetrics, StatusId, StopReason};
11use av_identity::IdentityValidator;
12use av_loopdetect::{BreakerAction, BreakerState, Embedder, HashEmbedder, NoopVectorSink, VectorSink};
13use av_receipts::Signer;
14use av_sandbox::{Sandbox, ToolVerdict};
15use av_state::{ActionBudget, BudgetDecision, StateStore};
16use axum::http::{HeaderMap, HeaderName, HeaderValue};
17use serde_json::Value;
18use std::sync::Arc;
19use std::time::Instant;
20
21pub(crate) const SESSION_HEADER: &str = "x-av-session";
22const WORKFLOW_HEADER: &str = "x-av-workflow";
23pub(crate) const MIDDLEWARE_US_HEADER: &str = "x-av-middleware-us";
24
25/// TCP connect timeout for every outbound HTTPS client the harness builds.
26pub const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
27
28/// Fallback identity scope when the tool name cannot be parsed from the request.
29pub(crate) const TOOL_INVOKE_SCOPE: &str = "tool:invoke";
30
31/// Identity scope required to invoke a specific tool by name.
32pub(crate) fn tool_scope(tool: &str) -> String {
33    format!("tool:{tool}")
34}
35
36/// Shared application state passed through HTTP handlers and background tasks.
37#[derive(Clone)]
38pub struct AppState {
39    /// Versioned harness configuration.
40    pub config: Arc<HarnessConfig>,
41    /// Atomic quota and action-budget state.
42    pub store: Arc<dyn StateStore>,
43    /// MCP schema, policy, and action-budget sandbox.
44    pub sandbox: Arc<Sandbox>,
45    /// Event Bridge backend used by asynchronous workers and by lifecycle
46    /// finalization (close/promote publish through it inline).
47    pub bridge: Arc<dyn EventBus>,
48    /// Live session registry.
49    pub sessions: Arc<SessionRegistry>,
50    /// Bounded non-blocking worker queue.
51    pub worker: WorkerHandle,
52    /// Optional NHI validator. Required in production identity mode.
53    pub identity: Option<Arc<IdentityValidator>>,
54    /// Prometheus-compatible metrics registry.
55    pub metrics: Arc<Registry>,
56    /// Reused upstream HTTP client.
57    pub client: reqwest::Client,
58    /// Static credential injected into every chat-completions forward
59    /// (resolved once at startup; value is marked sensitive).
60    pub(crate) upstream_auth: Option<(HeaderName, HeaderValue)>,
61    /// Bearer credential injected into every tool-upstream forward.
62    pub(crate) tool_auth: Option<HeaderValue>,
63    /// Asynchronous session close and promotion service.
64    pub finalizer: Finalizer,
65    pub(crate) journal_key: [u8; 32],
66}
67
68/// How the harness authenticates to the chat upstream, for startup logs
69/// and `avctl doctor` — never contains key material.
70pub fn describe_upstream_auth(config: &HarnessConfig) -> String {
71    if config.upstream_authorization_passthrough {
72        "passthrough(client Authorization)".to_owned()
73    } else if let Some(env) = config.upstream_api_key_env.as_deref() {
74        format!("api-key from ${env} in header {:?}", config.upstream_auth_header)
75    } else if let Some(file) = config.upstream_api_key_file.as_deref() {
76        format!(
77            "api-key from file {file} in header {:?}",
78            config.upstream_auth_header
79        )
80    } else {
81        "none".to_owned()
82    }
83}
84
85/// Resolve the configured upstream credential into a ready header pair.
86///
87/// Key material is accepted only from an environment variable or an
88/// owner-only file — never from TOML or argv — and the resulting header
89/// value is marked sensitive so `Debug` output redacts it.
90pub(crate) fn resolve_upstream_auth(
91    config: &HarnessConfig,
92) -> Result<Option<(HeaderName, HeaderValue)>, PipelineError> {
93    let key = match read_secret(
94        config.upstream_api_key_env.as_deref(),
95        config.upstream_api_key_file.as_deref(),
96        "upstream API key",
97    )? {
98        Some(key) => key,
99        None => return Ok(None),
100    };
101    let name = HeaderName::try_from(config.upstream_auth_header.as_str()).map_err(|_| {
102        PipelineError::Upstream(format!(
103            "upstream_auth_header {:?} is not a valid header name",
104            config.upstream_auth_header
105        ))
106    })?;
107    let rendered = if config.upstream_auth_scheme.is_empty() {
108        key
109    } else {
110        format!("{} {key}", config.upstream_auth_scheme)
111    };
112    let mut value = HeaderValue::from_str(&rendered).map_err(|_| {
113        PipelineError::Upstream(
114            "upstream API key contains bytes that cannot appear in an HTTP header".to_owned(),
115        )
116    })?;
117    value.set_sensitive(true);
118    Ok(Some((name, value)))
119}
120
121/// Resolve the optional tool-upstream bearer token.
122pub(crate) fn resolve_tool_auth(config: &HarnessConfig) -> Result<Option<HeaderValue>, PipelineError> {
123    let token = match read_secret(
124        config.tool_upstream_bearer_env.as_deref(),
125        config.tool_upstream_bearer_file.as_deref(),
126        "tool upstream bearer token",
127    )? {
128        Some(token) => token,
129        None => return Ok(None),
130    };
131    let mut value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
132        PipelineError::Upstream(
133            "tool upstream bearer token contains bytes that cannot appear in an HTTP header".to_owned(),
134        )
135    })?;
136    value.set_sensitive(true);
137    Ok(Some(value))
138}
139
140/// Read a secret from an env var or an owner-only file, trimming
141/// surrounding whitespace. A configured-but-missing source is a loud startup error:
142/// silently proxying unauthenticated would produce baffling upstream 401s.
143fn read_secret(
144    env_name: Option<&str>,
145    file_path: Option<&str>,
146    what: &str,
147) -> Result<Option<String>, PipelineError> {
148    read_secret_from(|name| std::env::var(name).ok(), env_name, file_path, what)
149}
150
151/// Testable core of [`read_secret`] with an injected environment.
152fn read_secret_from(
153    get_env: impl Fn(&str) -> Option<String>,
154    env_name: Option<&str>,
155    file_path: Option<&str>,
156    what: &str,
157) -> Result<Option<String>, PipelineError> {
158    if let Some(name) = env_name {
159        let value = get_env(name).ok_or_else(|| {
160            PipelineError::Upstream(format!(
161                "{what}: environment variable {name} is not set (export it or update the config)"
162            ))
163        })?;
164        let value = value.trim().to_owned();
165        if value.is_empty() {
166            return Err(PipelineError::Upstream(format!(
167                "{what}: environment variable {name} is set but empty"
168            )));
169        }
170        return Ok(Some(value));
171    }
172    if let Some(path) = file_path {
173        require_owner_only_secret(std::path::Path::new(path))
174            .map_err(|error| PipelineError::Upstream(format!("{what}: {error}")))?;
175        let value = std::fs::read_to_string(path)
176            .map_err(|error| PipelineError::Upstream(format!("{what}: read {path}: {error}")))?;
177        let value = value.trim().to_owned();
178        if value.is_empty() {
179            return Err(PipelineError::Upstream(format!("{what}: file {path} is empty")));
180        }
181        return Ok(Some(value));
182    }
183    Ok(None)
184}
185
186/// Same posture as the signing-seed loader: refuse symlinks (a pre-planted
187/// link would fool the mode check, CWE-59) and group/other-readable modes
188/// on Unix. Windows deployments rely on operator-set ACLs.
189fn require_owner_only_secret(path: &std::path::Path) -> Result<(), String> {
190    #[cfg(unix)]
191    {
192        use std::os::unix::fs::MetadataExt as _;
193        let metadata = std::fs::symlink_metadata(path)
194            .map_err(|error| format!("stat secret file {}: {error}", path.display()))?;
195        let file_type = metadata.file_type();
196        if file_type.is_symlink() {
197            return Err(format!(
198                "secret file {} is a symbolic link; refusing to follow",
199                path.display()
200            ));
201        }
202        if !file_type.is_file() {
203            return Err(format!("secret file {} is not a regular file", path.display()));
204        }
205        let mode = metadata.mode() & 0o777;
206        if mode & 0o077 != 0 {
207            return Err(format!(
208                "secret file {} has mode 0o{mode:03o}; must be owner-only (chmod 600 {})",
209                path.display(),
210                path.display()
211            ));
212        }
213    }
214    #[cfg(not(unix))]
215    {
216        let _ = path;
217    }
218    Ok(())
219}
220
221/// A request after all local hot-path gates have passed.
222pub struct PreparedRequest {
223    /// Session bound to this request.
224    pub session: Arc<Session>,
225    /// Identity validated for this request.
226    pub identity: AgentIdentity,
227    /// Payload forwarded to the upstream provider after compression.
228    pub payload: Value,
229    /// Total local middleware time before upstream I/O.
230    pub middleware_us: u64,
231    lease: SessionLease,
232    response_permit: Option<ResponsePermit>,
233    response_attempt_id: String,
234    /// Client `Authorization` header captured for passthrough mode only.
235    client_authorization: Option<HeaderValue>,
236}
237
238/// Provider response paired with its active session lease.
239pub struct ForwardedResponse {
240    /// Provider HTTP response.
241    pub response: reqwest::Response,
242    pub(crate) lease: SessionLease,
243    pub(crate) response_permit: Option<ResponsePermit>,
244    pub(crate) response_marker: Option<String>,
245    pub(crate) response_attempt_id: String,
246}
247
248/// Hot-path failures, each carrying an HTTP status mapping.
249#[derive(Debug, thiserror::Error)]
250#[non_exhaustive]
251pub enum PipelineError {
252    /// A request header was malformed or inconsistent.
253    #[error("bad request: {0}")]
254    BadRequest(String),
255    /// Identity was missing or invalid.
256    #[error("unauthorized: {0}")]
257    Unauthorized(String),
258    /// A stateful quota or loop breaker blocked execution.
259    #[error("request blocked: {0}")]
260    Blocked(String),
261    /// The upstream provider request failed.
262    #[error("upstream request failed: {0}")]
263    Upstream(String),
264    /// The breaker requested immediate connection closure.
265    #[error("connection aborted: {0}")]
266    Abort(String),
267    /// Required audit capture infrastructure is unavailable.
268    #[error("audit capture unavailable: {0}")]
269    Unavailable(String),
270}
271
272impl PipelineError {
273    /// HTTP status code corresponding to this failure.
274    ///
275    /// * `Blocked` / `Abort` are permanent policy verdicts for the current
276    ///   session: 403 / 409 stop mainstream SDK auto-retry loops that
277    ///   would otherwise interpret 429 as transient rate limiting and
278    ///   burn budget re-hitting the same breaker.
279    /// * `Unavailable` is transient: 503 is paired with `Retry-After` at
280    ///   the response layer so intermediaries and clients back off in
281    ///   bounded fashion (RFC 7231 §7.1.3).
282    /// * `Unauthorized` is paired with `WWW-Authenticate` at the
283    ///   response layer (RFC 7235 §3.1).
284    pub fn status(&self) -> axum::http::StatusCode {
285        match self {
286            Self::BadRequest(_) => axum::http::StatusCode::BAD_REQUEST,
287            Self::Unauthorized(_) => axum::http::StatusCode::UNAUTHORIZED,
288            Self::Blocked(_) => axum::http::StatusCode::FORBIDDEN,
289            Self::Upstream(_) => axum::http::StatusCode::BAD_GATEWAY,
290            Self::Abort(_) => axum::http::StatusCode::CONFLICT,
291            Self::Unavailable(_) => axum::http::StatusCode::SERVICE_UNAVAILABLE,
292        }
293    }
294}
295
296impl AppState {
297    /// Build fully wired application state with a bounded worker and shared
298    /// lifecycle services.
299    pub fn new(
300        config: HarnessConfig,
301        store: Arc<dyn StateStore>,
302        sandbox: Arc<Sandbox>,
303        bridge: Arc<dyn EventBus>,
304        identity: Option<Arc<IdentityValidator>>,
305        signer: Arc<dyn Signer>,
306    ) -> Result<Self, PipelineError> {
307        Self::new_with_embedder(
308            config,
309            store,
310            sandbox,
311            bridge,
312            identity,
313            signer,
314            Arc::new(HashEmbedder::default()),
315        )
316    }
317
318    /// Build application state with an explicit embedding backend.
319    #[allow(clippy::too_many_arguments)]
320    pub fn new_with_embedder(
321        config: HarnessConfig,
322        store: Arc<dyn StateStore>,
323        sandbox: Arc<Sandbox>,
324        bridge: Arc<dyn EventBus>,
325        identity: Option<Arc<IdentityValidator>>,
326        signer: Arc<dyn Signer>,
327        embedder: Arc<dyn Embedder>,
328    ) -> Result<Self, PipelineError> {
329        Self::new_with_backends(
330            config,
331            store,
332            sandbox,
333            bridge,
334            identity,
335            signer,
336            embedder,
337            Arc::new(NoopVectorSink),
338        )
339    }
340
341    /// Build application state with explicit embedding and vector backends.
342    #[allow(clippy::too_many_arguments)]
343    pub fn new_with_backends(
344        config: HarnessConfig,
345        store: Arc<dyn StateStore>,
346        sandbox: Arc<Sandbox>,
347        bridge: Arc<dyn EventBus>,
348        identity: Option<Arc<IdentityValidator>>,
349        signer: Arc<dyn Signer>,
350        embedder: Arc<dyn Embedder>,
351        vector_sink: Arc<dyn VectorSink>,
352    ) -> Result<Self, PipelineError> {
353        Self::new_with_backends_and_metrics(
354            config,
355            store,
356            sandbox,
357            bridge,
358            identity,
359            signer,
360            embedder,
361            vector_sink,
362            Arc::new(Registry::new()),
363        )
364    }
365
366    /// Build application state reusing a pre-existing metrics registry.
367    ///
368    /// `main.rs` uses this so counters registered outside `AppState`
369    /// (JWKS refresh errors, HTTP shutdown drain timeouts) live on the
370    /// same registry that gets scraped at `/metrics` — otherwise their
371    /// samples would be invisible to Prometheus.
372    #[allow(clippy::too_many_arguments)]
373    pub fn new_with_backends_and_metrics(
374        config: HarnessConfig,
375        store: Arc<dyn StateStore>,
376        sandbox: Arc<Sandbox>,
377        bridge: Arc<dyn EventBus>,
378        identity: Option<Arc<IdentityValidator>>,
379        signer: Arc<dyn Signer>,
380        embedder: Arc<dyn Embedder>,
381        vector_sink: Arc<dyn VectorSink>,
382        metrics: Arc<Registry>,
383    ) -> Result<Self, PipelineError> {
384        let config = Arc::new(config);
385        // Every stage of the `av_stage_duration_seconds` series MUST
386        // share the same bucket bounds, otherwise
387        // `histogram_quantile(0.99, sum by(le) (rate(...[5m])))` in
388        // Prometheus silently produces nonsense: the `le` values from
389        // different label combinations do not align and the summation
390        // is undefined. Use WIDE_LATENCY_BOUNDS_US for all stages so
391        // a future stage that spans real I/O fits without a bounds
392        // migration — the fast stages just get more granular
393        // low-end buckets they will never light up, which is fine.
394        for stage in ["identity", "quota", "sanitize", "compression", "dispatch"] {
395            metrics.histogram_with_bounds(
396                &format!("av_stage_duration_seconds{{stage=\"{stage}\"}}"),
397                "Harness stage latency",
398                av_core::metrics::WIDE_LATENCY_BOUNDS_US,
399            );
400        }
401        metrics.counter(
402            "av_events_dropped_total{stage=\"worker_queue\"}",
403            "Worker jobs dropped",
404        );
405        metrics.counter(
406            "av_worker_panics_total",
407            "Worker job panics isolated by supervisor",
408        );
409        metrics.counter("av_worker_errors_total", "Worker jobs that failed");
410        metrics.counter("av_sessions_finalized_total", "Sessions finalized");
411        metrics.counter("av_sessions_promoted_total", "Unsigned sessions promoted");
412        metrics.counter("av_reconcile_errors_total", "Reconciliation errors");
413        // Round-42 F2: pre-register recovery-skipped counters so
414        // Prometheus `absent()` alerts do not fire on healthy nodes
415        // that have never hit a per-session recovery error, and so
416        // dashboards render flat-zero instead of "No data". Each
417        // series must exist on `/metrics` from boot; `Registry::counter`
418        // is otherwise lazy and only inserts on first `.inc()`.
419        metrics.counter(
420            "av_signed_recovery_skipped_total",
421            "Signed sessions skipped during recovery due to per-session errors (round-41 F1)",
422        );
423        metrics.counter(
424            "av_unsigned_recovery_skipped_total",
425            "Unsigned step-journal consolidations skipped during recovery due to per-session errors (round-41 F1)",
426        );
427        metrics.counter(
428            "av_atif_trajectory_recovery_skipped_total",
429            "ATIF trajectories skipped during recovery due to per-session errors (round-42 F1)",
430        );
431        metrics.counter(
432            "av_pending_close_completion_failed_total",
433            "Pending-close completions that failed to finish their tail (round-43 F1)",
434        );
435        for reason in [
436            "too_large",
437            "read_error",
438            "invalid_json",
439            "nonconformant",
440            "unauthenticated",
441            "provenance",
442        ] {
443            metrics.counter(
444                &format!("av_atif_recovery_skipped_total{{reason=\"{reason}\"}}"),
445                "ATIF spool files skipped during recovery",
446            );
447        }
448        metrics.histogram("av_receipt_sign_duration_seconds", "Receipt signing latency");
449        // Reconciler ticks scan the ATIF spool dir, which can be large;
450        // finalisation waits for worker drain + broker publish. Wide
451        // bounds keep long-tail p99 useful under load.
452        metrics.histogram_with_bounds(
453            "av_reconcile_duration_seconds",
454            "Idle reconciliation duration",
455            av_core::metrics::WIDE_LATENCY_BOUNDS_US,
456        );
457        metrics.histogram_with_bounds(
458            "av_session_finalize_duration_seconds",
459            "Session finalization latency",
460            av_core::metrics::WIDE_LATENCY_BOUNDS_US,
461        );
462        for endpoint in ["stats", "list", "detail"] {
463            metrics.histogram(
464                &format!("av_dashboard_request_duration_seconds{{endpoint=\"{endpoint}\"}}"),
465                "Dashboard endpoint latency",
466            );
467            metrics.counter(
468                &format!("av_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"ok\"}}"),
469                "Dashboard endpoint requests served",
470            );
471            metrics.counter(
472                &format!("av_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"not_found\"}}"),
473                "Dashboard endpoint requests that could not be served",
474            );
475        }
476        let sessions = Arc::new(SessionRegistry::new());
477        let journal_key = crate::journal::key_from_signer(signer.as_ref());
478        let worker = crate::worker::spawn_worker_with_spool_authenticated(
479            config.worker_channel_capacity,
480            Arc::clone(&bridge),
481            embedder,
482            vector_sink,
483            Some(std::path::PathBuf::from(&config.atif_spool_dir)),
484            journal_key,
485            Arc::clone(&metrics),
486        );
487        let finalizer = Finalizer::with_bridge(
488            signer,
489            std::path::PathBuf::from(&config.atif_spool_dir),
490            Arc::clone(&metrics),
491            Arc::clone(&bridge),
492        )
493        .with_state_store(Arc::clone(&store));
494        let mut client_builder = reqwest::Client::builder()
495            .connect_timeout(HTTP_CONNECT_TIMEOUT)
496            .redirect(reqwest::redirect::Policy::none())
497            // Providers (OpenAI, Anthropic, Azure, Bedrock fronting)
498            // downgrade or block empty / generic UAs; identifying
499            // ourselves also gives operators a stable string for
500            // provider-side triage. Version is baked in at compile
501            // time so a rolling deploy makes the change visible.
502            .user_agent(concat!("AgentVisor AI/", env!("CARGO_PKG_VERSION")))
503            // TCP keepalive so pooled connections behind NAT/L4 LBs
504            // with short idle windows (AWS NLB 350 s, GCP TCP 600 s,
505            // stateful FWs often 60-120 s) do not turn every first
506            // request per pool cycle into a full TCP+TLS re-handshake
507            // that manifests as a spurious 502/`connection reset`.
508            .tcp_keepalive(std::time::Duration::from_secs(30))
509            // Cap pool idle at 60 s: shorter than any realistic NAT
510            // window, longer than any burst-of-requests batch. Bounds
511            // the pool memory footprint and forces frequent-enough
512            // TLS refresh under a rolling cert rotation.
513            .pool_idle_timeout(std::time::Duration::from_secs(60));
514        // Round-32 F4: apply a read-timeout floor unconditionally so an
515        // adversarial or merely broken upstream (chat OR tool) cannot pin
516        // a session lease + WorkerPermit + tool-intent claim
517        // indefinitely by accepting the request and then never
518        // responding. TCP keepalive above only detects a hung
519        // *connection*, not a slow/silent HTTP response. Operators can
520        // widen or override via `upstream_read_timeout_s`; the shipped
521        // default (60 s) is well past any realistic first-token
522        // latency (Claude p99 ~15 s, GPT-4 p99 ~30 s) but firm enough
523        // that a stalled provider surfaces as a definite 502 rather
524        // than a resource-starving hang.
525        const DEFAULT_UPSTREAM_READ_TIMEOUT_S: u64 = 60;
526        let read_timeout_s = config
527            .upstream_read_timeout_s
528            .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
529        client_builder = client_builder.read_timeout(std::time::Duration::from_secs(read_timeout_s));
530        if config.upstream_http2_prior_knowledge {
531            client_builder = client_builder.http2_prior_knowledge();
532        }
533        let client = client_builder
534            .build()
535            .map_err(|error| PipelineError::Upstream(error.to_string()))?;
536        let upstream_auth = resolve_upstream_auth(&config)?;
537        let tool_auth = resolve_tool_auth(&config)?;
538        Ok(Self {
539            config,
540            store,
541            sandbox,
542            bridge,
543            sessions,
544            worker,
545            identity,
546            metrics,
547            client,
548            upstream_auth,
549            tool_auth,
550            finalizer,
551            journal_key,
552        })
553    }
554
555    /// Run identity, breaker, quota, sanitize, compression, and asynchronous
556    /// dispatch in the mandated order without awaiting worker or upstream I/O.
557    pub fn prepare_chat(
558        &self,
559        headers: &HeaderMap,
560        mut payload: Value,
561    ) -> Result<PreparedRequest, PipelineError> {
562        let total_started = Instant::now();
563        let session_id = session_id(headers)?;
564        let workflow = workflow(headers, &self.config.default_workflow)?;
565
566        let stage = Instant::now();
567        let identity = match self.resolve_identity(headers, Some(&self.config.chat_scope)) {
568            Ok(identity) => identity,
569            Err(error) => {
570                self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
571                return Err(error);
572            }
573        };
574        self.observe_stage("identity", stage);
575
576        let session = self
577            .sessions
578            .get_or_open(&session_id, workflow, &identity, &self.config.breaker);
579        let admission = session.admission_guard();
580        validate_session_binding(&session, workflow, &identity)?;
581        session.refresh_identity(&identity);
582        if session.is_closed() {
583            return Err(PipelineError::BadRequest("session is already closed".to_owned()));
584        }
585        if session.capture_failed() {
586            return Err(PipelineError::Unavailable(
587                "session audit capture is incomplete".to_owned(),
588            ));
589        }
590        session.touch();
591        if session.loop_state.state() == BreakerState::Open {
592            match session.loop_state.action() {
593                BreakerAction::Reject => {
594                    return Err(PipelineError::Blocked(
595                        "semantic loop circuit breaker is open".to_owned(),
596                    ));
597                }
598                BreakerAction::Abort => {
599                    return Err(PipelineError::Abort(
600                        "semantic loop circuit breaker requested connection close".to_owned(),
601                    ));
602                }
603                BreakerAction::Inject => {
604                    inject_corrective_message(&mut payload)?;
605                    session.loop_state.reset();
606                }
607                _ => {
608                    return Err(PipelineError::Blocked(
609                        "unsupported semantic loop enforcement action".to_owned(),
610                    ));
611                }
612            }
613        }
614
615        // Fused acquire: one worker slot + one response slot atomically.
616        // If the worker slot succeeds and the response slot fails, the
617        // worker permit drops via RAII (its OwnedSemaphorePermit and
618        // mpsc reservation release cleanly), so the caller never sees
619        // an orphaned half-reservation. Distinct
620        // `av_events_dropped_total{stage=worker_queue|response_slot}`
621        // counters let operators tell which one exhausted.
622        let permits = self
623            .worker
624            .try_reserve_pair(&session_id)
625            .map_err(|error| PipelineError::Unavailable(error.to_string()))?;
626        let worker_permit = permits.worker;
627        let response_permit = permits.response;
628
629        let stage = Instant::now();
630        let prompt_tokens = av_core::tokens::approx_tokens_json(&payload);
631        let quota = match ActionBudget::new(self.store.as_ref(), &session_id, &self.config.budget)
632            .try_tokens(prompt_tokens)
633        {
634            Ok(quota) => quota,
635            Err(error) => {
636                let error = PipelineError::Blocked(format!("quota backend failed closed: {error}"));
637                worker_permit.submit(self.failure_job(
638                    Arc::clone(&session),
639                    identity.clone(),
640                    StopReason::BudgetExceeded,
641                    error.to_string(),
642                ));
643                return Err(error);
644            }
645        };
646        if let BudgetDecision::Refused { limit, cap } = quota {
647            let error = PipelineError::Blocked(format!("{limit} exceeded (cap {cap})"));
648            worker_permit.submit(self.failure_job(
649                Arc::clone(&session),
650                identity.clone(),
651                StopReason::BudgetExceeded,
652                error.to_string(),
653            ));
654            return Err(error);
655        }
656        self.observe_stage("quota", stage);
657
658        let stage = Instant::now();
659        if let Err(reason) = self.sandbox.sanitize("chat/completions", &payload) {
660            let error = PipelineError::Blocked(reason);
661            worker_permit.submit(self.failure_job(
662                Arc::clone(&session),
663                identity.clone(),
664                StopReason::PolicyBlocked,
665                error.to_string(),
666            ));
667            return Err(error);
668        }
669        self.observe_stage("sanitize", stage);
670
671        let stage = Instant::now();
672        let compression = if self.config.compression_enabled {
673            av_compress::compress(&payload, &av_compress::CompressionConfig::default())
674        } else {
675            av_compress::CompressionOutcome {
676                payload,
677                tokens_before: prompt_tokens,
678                tokens_after: prompt_tokens,
679                changed: false,
680            }
681        };
682        self.observe_stage("compression", stage);
683
684        let stage = Instant::now();
685        let text = last_message_text(&compression.payload);
686        let atif = match atif_capture_from_request(&compression.payload) {
687            Ok(atif) => atif,
688            Err(error) => {
689                worker_permit.submit(self.failure_job(
690                    Arc::clone(&session),
691                    identity.clone(),
692                    StopReason::Other,
693                    error.to_string(),
694                ));
695                return Err(error);
696            }
697        };
698        let analyze_loop = atif.source == av_atif::Source::Agent;
699        let response_attempt_id = av_core::new_event_uid();
700        let job = WorkerJob {
701            session: Arc::clone(&session),
702            identity: identity.clone(),
703            class: EventClass::Compression,
704            payload: serde_json::json!({
705                "changed": compression.changed,
706                "tokens_before": compression.tokens_before,
707                "tokens_after": compression.tokens_after,
708            }),
709            text,
710            analyze_loop,
711            status: StatusId::Success,
712            stop_reason: None,
713            native_stop_reason: None,
714            metrics: EventMetrics {
715                prompt_tokens: Some(compression.tokens_after),
716                completion_tokens: Some(0),
717                cached_tokens: Some(0),
718                pruned_tokens: Some(compression.pruned_tokens()),
719                pruning_ratio_millis: Some(compression.pruning_ratio_millis()),
720            },
721            cost_usd_micros: 0,
722            atif: Some(atif),
723            response_marker: None,
724            response_attempt: Some(crate::worker::ResponseAttempt {
725                id: response_attempt_id.clone(),
726                terminal: false,
727            }),
728        };
729        worker_permit.submit(job);
730        self.observe_stage("dispatch", stage);
731        let lease = SessionLease::new(Arc::clone(&session));
732        drop(admission);
733
734        Ok(PreparedRequest {
735            session,
736            identity,
737            payload: compression.payload,
738            middleware_us: elapsed_us(total_started),
739            lease,
740            response_permit: Some(response_permit),
741            response_attempt_id,
742            client_authorization: if self.config.upstream_authorization_passthrough {
743                single_header(headers, "authorization")?.cloned()
744            } else {
745                None
746            },
747        })
748    }
749
750    /// Run synchronous local gates without waiting for off-path journal,
751    /// embedding, or broker work. When a session token budget is
752    /// configured (`budget.max_tokens`, prompt+completion combined) the
753    /// gates run on the blocking pool;
754    /// otherwise they are cheap enough to run inline.
755    pub async fn prepare_chat_nonblocking(
756        &self,
757        headers: &HeaderMap,
758        payload: Value,
759    ) -> Result<PreparedRequest, PipelineError> {
760        if self.config.budget.max_tokens.is_none() {
761            return self.prepare_chat(headers, payload);
762        }
763        let state = self.clone();
764        let headers = headers.clone();
765        tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload))
766            .await
767            .map_err(|error| PipelineError::Unavailable(error.to_string()))?
768    }
769
770    /// Prepare a request and wait until its audit record is durably captured
771    /// before the provider can observe it.
772    pub async fn prepare_chat_durable(
773        &self,
774        headers: &HeaderMap,
775        payload: Value,
776    ) -> Result<PreparedRequest, PipelineError> {
777        if let Ok(id) = session_id(headers) {
778            if let Some(session) = self.sessions.get(&id) {
779                session.wait_for_worker_jobs().await;
780                if session.capture_failed() {
781                    return Err(PipelineError::Unavailable(
782                        "session audit capture is incomplete".to_owned(),
783                    ));
784                }
785            }
786        }
787        let state = self.clone();
788        let headers = headers.clone();
789        let mut prepared = tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload))
790            .await
791            .map_err(|error| PipelineError::Unavailable(error.to_string()))??;
792        prepared.session.wait_for_worker_jobs().await;
793        if prepared.session.capture_failed() {
794            let error = PipelineError::Unavailable(
795                "request audit capture failed before provider dispatch".to_owned(),
796            );
797            self.abandon_prepared(&mut prepared, StopReason::Other, &error.to_string());
798            return Err(error);
799        }
800        if prepared.session.loop_state.state() == BreakerState::Open {
801            let error = match prepared.session.loop_state.action() {
802                BreakerAction::Abort => {
803                    PipelineError::Abort("semantic loop circuit breaker opened during audit".to_owned())
804                }
805                _ => PipelineError::Blocked(
806                    "semantic loop circuit breaker opened during audit; retry required".to_owned(),
807                ),
808            };
809            self.abandon_prepared(&mut prepared, StopReason::LoopDetected, &error.to_string());
810            return Err(error);
811        }
812        Ok(prepared)
813    }
814
815    /// Close out a prepared request that will never reach `forward_chat`.
816    ///
817    /// `prepare_chat` journals a non-terminal [`crate::worker::ResponseAttempt`]
818    /// with its admission record; the matching terminal record normally comes
819    /// from `forward_chat`'s failure path or the response relay. A caller that
820    /// abandons the request after admission must submit the terminal failure
821    /// record itself — otherwise the journal ends with a dangling non-terminal
822    /// attempt and a later crash-recovery scan quarantines the whole session
823    /// over a request the client already saw fail.
824    fn abandon_prepared(&self, prepared: &mut PreparedRequest, stop_reason: StopReason, reason: &str) {
825        let Some(permit) = prepared.response_permit.take() else {
826            return;
827        };
828        let mut job = self.failure_job(
829            Arc::clone(&prepared.session),
830            prepared.identity.clone(),
831            stop_reason,
832            reason.to_owned(),
833        );
834        job.response_attempt = Some(crate::worker::ResponseAttempt {
835            id: prepared.response_attempt_id.clone(),
836            terminal: true,
837        });
838        // Best-effort submit: if the shard is momentarily full, the
839        // response-slot counter has already been bumped inside submit
840        // and the caller (this path) is already an abandon flow, so
841        // there is nothing further to do.
842        let _ = permit.submit(&self.worker, job);
843    }
844
845    /// Forward a prepared OpenAI-compatible request to the configured provider.
846    pub async fn forward_chat(&self, request: PreparedRequest) -> Result<ForwardedResponse, PipelineError> {
847        let PreparedRequest {
848            session,
849            identity,
850            payload,
851            lease,
852            response_permit,
853            response_attempt_id,
854            client_authorization,
855            ..
856        } = request;
857        let url = format!(
858            "{}{}",
859            self.config.upstream_url.trim_end_matches('/'),
860            self.config.upstream_chat_path
861        );
862        // Digest the request payload so the in-flight marker can be
863        // matched to the observed response bytes at recovery time.
864        //
865        // `serde_json::to_vec` on a `Value` is effectively infallible
866        // (Value can only carry JSON-serialisable data), but we handle
867        // the theoretical error path anyway: falling back to
868        // `sha256(b"")` — the well-known empty digest — would make
869        // every concurrent failed serialisation collide on the same
870        // request_digest, silently violating the marker's
871        // one-to-one-with-request invariant. Fall back to a
872        // session-id-derived digest so a hypothetical failure at
873        // least keeps distinct sessions distinct.
874        let request_digest = match serde_json::to_vec(&payload) {
875            Ok(bytes) => av_core::digest::sha256_hex(&bytes),
876            Err(error) => {
877                tracing::error!(
878                    %error,
879                    session = %session.id,
880                    "failed to serialise chat payload for request digest; falling back to session-derived digest"
881                );
882                av_core::digest::sha256_hex(session.id.as_bytes())
883            }
884        };
885        let response_marker = crate::worker::create_response_marker(
886            std::path::Path::new(&self.config.atif_spool_dir),
887            &self.journal_key,
888            &session.id,
889            request_digest,
890        )
891        .await
892        .map_err(|error| {
893            tracing::warn!(%error, session = %session.id, "could not write in-flight response marker");
894        })
895        .ok();
896        let mut upstream_request = self.client.post(url).json(&payload);
897        if let Some((name, value)) = &self.upstream_auth {
898            upstream_request = upstream_request.header(name.clone(), value.clone());
899        } else if let Some(authorization) = client_authorization {
900            // Passthrough mode: the client's own credential travels to the
901            // upstream. `validate()` guarantees this is mutually exclusive
902            // with static keys and with NHI identity enforcement.
903            upstream_request = upstream_request.header(reqwest::header::AUTHORIZATION, authorization);
904        }
905        match upstream_request.send().await {
906            Ok(response) => Ok(ForwardedResponse {
907                response,
908                lease,
909                response_permit,
910                response_marker,
911                response_attempt_id,
912            }),
913            Err(error) => {
914                // Round-35 F1: `reqwest::Error::Display` embeds the
915                // request URL (see the round-34 F4 rule at
916                // routes.rs::read_limited_tool_response — the same
917                // rule applies here). `%error` on `reqwest::Error`
918                // would render `error sending request for url
919                // (https://api.openai.com/v1/chat/completions): dns
920                // error: ...` into every OTLP sink. In multi-tenant
921                // / air-gapped deployments the upstream URL is not
922                // something the operator wants leaked to the
923                // customer's SIEM (it may be an internal LiteLLM
924                // router, an on-prem Azure resource name, a
925                // per-tenant model deployment path). Log the
926                // stable classifier + reqwest's structured
927                // predicates only.
928                //
929                // Also propagate the classifier — NOT
930                // `error.to_string()` — into the persisted failure
931                // event so the URL doesn't land in the on-disk
932                // journal or in the Bridge-published failure
933                // record either.
934                let client_reason = classify_upstream_error(&error);
935                tracing::warn!(
936                    session = %session.id,
937                    category = client_reason,
938                    error.status = ?error.status(),
939                    error.is_timeout = error.is_timeout(),
940                    error.is_connect = error.is_connect(),
941                    error.is_body = error.is_body(),
942                    "upstream forwarding failed"
943                );
944                let client_error = PipelineError::Upstream(client_reason.to_owned());
945                let persisted_reason = format!("upstream_{client_reason}");
946                if let Some(permit) = response_permit {
947                    let mut job =
948                        self.failure_job(session, identity, StopReason::Other, persisted_reason.clone());
949                    job.response_marker = response_marker;
950                    job.response_attempt = Some(crate::worker::ResponseAttempt {
951                        id: response_attempt_id,
952                        terminal: true,
953                    });
954                    // Best-effort: shard may be momentarily full, in
955                    // which case the response-slot counter is bumped
956                    // and the failure event falls back to the plain
957                    // enqueue_failure path below on the next tick.
958                    let _ = permit.submit(&self.worker, job);
959                } else {
960                    self.enqueue_failure(session, identity, StopReason::Other, persisted_reason)?;
961                }
962                Err(client_error)
963            }
964        }
965    }
966
967    /// Intercept one MCP JSON-RPC tool call, emit its OCSF verdict
968    /// asynchronously, and return the immediate authorization decision.
969    pub fn intercept_tool(&self, headers: &HeaderMap, raw: &[u8]) -> Result<ToolVerdict, PipelineError> {
970        self.intercept_tool_with_session(headers, raw)
971            .map(|(verdict, _session)| verdict)
972    }
973
974    /// Core of [`Self::intercept_tool`] that also returns the bound session.
975    ///
976    /// Callers that must await audit durability need the same session the
977    /// verdict was recorded under: re-deriving it from headers would mint a
978    /// fresh random id for a header-less request (see [`session_id`]) and
979    /// look up a session that does not exist.
980    fn intercept_tool_with_session(
981        &self,
982        headers: &HeaderMap,
983        raw: &[u8],
984    ) -> Result<(ToolVerdict, Arc<Session>), PipelineError> {
985        let session_id = session_id(headers)?;
986        let workflow = workflow(headers, &self.config.default_workflow)?;
987        let parsed_call = av_sandbox::parse_tool_call(raw).ok();
988        let required_scope = parsed_call
989            .as_ref()
990            .map(|request| tool_scope(&request.tool))
991            .unwrap_or_else(|| TOOL_INVOKE_SCOPE.to_owned());
992        let identity = match self.resolve_identity(headers, Some(&required_scope)) {
993            Ok(identity) => identity,
994            Err(error) => {
995                self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
996                return Err(error);
997            }
998        };
999        // Tool interception must NOT resurrect a closed session: a
1000        // tool call extends an in-progress conversation, so silently
1001        // opening a fresh session under the same id would let the
1002        // client accumulate tool calls past a signed receipt boundary
1003        // (chain-of-custody split). Chat requests use `get_or_open`
1004        // (recycles closed ids); the strict `_no_reopen` variant here
1005        // makes the `is_closed()` check below fire.
1006        let session =
1007            self.sessions
1008                .get_or_open_no_reopen(&session_id, workflow, &identity, &self.config.breaker);
1009        let admission = session.admission_guard();
1010        validate_session_binding(&session, workflow, &identity)?;
1011        session.refresh_identity(&identity);
1012        if session.is_closed() {
1013            return Err(PipelineError::BadRequest("session is already closed".to_owned()));
1014        }
1015        if session.capture_failed() {
1016            return Err(PipelineError::Unavailable(
1017                "session audit capture is incomplete".to_owned(),
1018            ));
1019        }
1020        session.touch();
1021        let worker_permit = self
1022            .worker
1023            .try_reserve(&session_id)
1024            .map_err(|error| PipelineError::Unavailable(error.to_string()))?;
1025        let verdict = match parsed_call.as_ref() {
1026            // `sandbox.check`'s budget gate spends before we return, so we must
1027            // veto workflow-mismatched consequential tools before it runs.
1028            Some(request)
1029                if workflow == Workflow::Unsigned
1030                    && self
1031                        .config
1032                        .consequential_tools
1033                        .iter()
1034                        .any(|required| required == &request.tool) =>
1035            {
1036                let reason = format!("tool {:?} requires a signed workflow", request.tool);
1037                ToolVerdict::Blocked {
1038                    tool: request.tool.clone(),
1039                    stage: "policy",
1040                    reason: reason.clone(),
1041                    response: av_sandbox::rpc::authorization_error(request.id.as_ref(), &reason),
1042                    elapsed_us: 0,
1043                }
1044            }
1045            _ => self.sandbox.check(self.store.as_ref(), &session_id, raw),
1046        };
1047        let (status, payload) = match &verdict {
1048            ToolVerdict::Allowed {
1049                tool,
1050                budget_remaining,
1051                elapsed_us,
1052                ..
1053            } => (
1054                StatusId::Success,
1055                serde_json::json!({
1056                    "tool": tool,
1057                    "allowed": true,
1058                    "budget_remaining": (*budget_remaining != u64::MAX)
1059                        .then_some(*budget_remaining),
1060                    "budget_unlimited": *budget_remaining == u64::MAX,
1061                    "decision_us": elapsed_us,
1062                }),
1063            ),
1064            ToolVerdict::Blocked {
1065                tool,
1066                stage,
1067                reason,
1068                elapsed_us,
1069                ..
1070            } => (
1071                StatusId::Failure,
1072                serde_json::json!({
1073                    "tool": tool,
1074                    "allowed": false,
1075                    "stage": stage,
1076                    "reason": reason,
1077                    "decision_us": elapsed_us,
1078                }),
1079            ),
1080        };
1081        let tool_call_id = parsed_call
1082            .as_ref()
1083            .and_then(|request| request.id.as_ref())
1084            .map(|value| value.as_str().map_or_else(|| value.to_string(), str::to_owned))
1085            .unwrap_or_else(av_core::new_event_uid);
1086        let tool_calls = parsed_call.as_ref().map(|request| {
1087            vec![av_atif::ToolCall {
1088                tool_call_id: tool_call_id.clone(),
1089                function_name: request.tool.clone(),
1090                arguments: request.arguments.clone(),
1091                extra: None,
1092            }]
1093        });
1094        let observation = parsed_call.as_ref().map(|_| av_atif::Observation {
1095            results: vec![av_atif::ObservationResult {
1096                source_call_id: Some(tool_call_id),
1097                content: Some(Value::String(payload.to_string())),
1098                subagent_trajectory_ref: None,
1099                extra: None,
1100            }],
1101        });
1102        let stop_reason = match &verdict {
1103            ToolVerdict::Allowed { .. } => None,
1104            ToolVerdict::Blocked { stage: "budget", .. } => Some(StopReason::BudgetExceeded),
1105            ToolVerdict::Blocked { .. } => Some(StopReason::PolicyBlocked),
1106        };
1107        worker_permit.submit(WorkerJob {
1108            session: Arc::clone(&session),
1109            identity,
1110            class: EventClass::ToolCall,
1111            payload,
1112            text: String::from_utf8_lossy(raw).into_owned(),
1113            analyze_loop: false,
1114            status,
1115            stop_reason,
1116            native_stop_reason: None,
1117            metrics: EventMetrics::default(),
1118            cost_usd_micros: 0,
1119            atif: Some(AtifCapture {
1120                source: av_atif::Source::Agent,
1121                message: Value::String("MCP tool authorization decision".to_owned()),
1122                reasoning_content: None,
1123                model_name: None,
1124                tool_calls,
1125                observation,
1126                llm_call_count: Some(0),
1127            }),
1128            response_marker: None,
1129            response_attempt: None,
1130        });
1131        drop(admission);
1132        Ok((verdict, session))
1133    }
1134
1135    /// Authorize a tool call and wait for its verdict event to become durable.
1136    pub async fn intercept_tool_durable(
1137        &self,
1138        headers: &HeaderMap,
1139        raw: &[u8],
1140    ) -> Result<ToolVerdict, PipelineError> {
1141        let state = self.clone();
1142        let owned_headers = headers.clone();
1143        let raw = raw.to_vec();
1144        let (verdict, session) =
1145            tokio::task::spawn_blocking(move || state.intercept_tool_with_session(&owned_headers, &raw))
1146                .await
1147                .map_err(|error| PipelineError::Unavailable(error.to_string()))??;
1148        session.wait_for_worker_jobs().await;
1149        if session.capture_failed() {
1150            return Err(PipelineError::Unavailable(
1151                "tool authorization audit capture failed".to_owned(),
1152            ));
1153        }
1154        Ok(verdict)
1155    }
1156
1157    /// Authorize a tool call on the blocking pool without waiting for the
1158    /// off-path event journal or broker publication.
1159    pub async fn intercept_tool_nonblocking(
1160        &self,
1161        headers: &HeaderMap,
1162        raw: &[u8],
1163    ) -> Result<ToolVerdict, PipelineError> {
1164        let state = self.clone();
1165        let owned_headers = headers.clone();
1166        let raw = raw.to_vec();
1167        tokio::task::spawn_blocking(move || state.intercept_tool(&owned_headers, &raw))
1168            .await
1169            .map_err(|error| PipelineError::Unavailable(error.to_string()))?
1170    }
1171
1172    pub(crate) fn lease_session(&self, headers: &HeaderMap) -> Result<SessionLease, PipelineError> {
1173        let id = session_id(headers)?;
1174        let session = self
1175            .sessions
1176            .get(&id)
1177            .ok_or_else(|| PipelineError::BadRequest("unknown session".to_owned()))?;
1178        session
1179            .try_lease()
1180            .ok_or_else(|| PipelineError::BadRequest("session is already closed".to_owned()))
1181    }
1182
1183    fn enqueue_transient_failure(
1184        &self,
1185        session_id: &str,
1186        stop_reason: StopReason,
1187        reason: String,
1188    ) -> Result<(), PipelineError> {
1189        let audit_session_id = format!("identity-rejected-{}", av_core::new_event_uid());
1190        let identity = AgentIdentity {
1191            version: "unknown".to_owned(),
1192            charter: "identity-rejected".into(),
1193            instance_uid: audit_session_id.clone(),
1194            ttl_remaining_s: None,
1195        };
1196        let session = Arc::new(Session::new(
1197            audit_session_id,
1198            Workflow::Signed,
1199            identity.clone(),
1200            self.config.breaker.clone(),
1201        ));
1202        // Cap the attacker-controlled `session_id` echo so a
1203        // maliciously long header cannot bloat every audit record.
1204        // 64 bytes is enough to keep well-formed UUIDs and legitimate
1205        // client-chosen ids intact; anything larger is truncated with
1206        // a marker so operators can still tell what the caller sent.
1207        const MAX_ECHO: usize = 64;
1208        let mut boundary = MAX_ECHO.min(session_id.len());
1209        while boundary < session_id.len() && !session_id.is_char_boundary(boundary) {
1210            boundary -= 1;
1211        }
1212        let truncated = boundary < session_id.len();
1213        let echo = &session_id[..boundary];
1214        let echo_marker = if truncated { "…(truncated)" } else { "" };
1215        self.enqueue_failure(
1216            session,
1217            identity,
1218            stop_reason,
1219            format!("requested session {echo:?}{echo_marker}: {reason}"),
1220        )
1221    }
1222
1223    fn enqueue_failure(
1224        &self,
1225        session: Arc<Session>,
1226        identity: AgentIdentity,
1227        stop_reason: StopReason,
1228        reason: String,
1229    ) -> Result<(), PipelineError> {
1230        self.worker
1231            .try_submit(self.failure_job(session, identity, stop_reason, reason))
1232            .map_err(|error| PipelineError::Unavailable(error.to_string()))
1233    }
1234
1235    fn failure_job(
1236        &self,
1237        session: Arc<Session>,
1238        identity: AgentIdentity,
1239        stop_reason: StopReason,
1240        reason: String,
1241    ) -> WorkerJob {
1242        let atif = (session.workflow == Workflow::Unsigned).then(|| AtifCapture {
1243            source: av_atif::Source::Agent,
1244            message: Value::String(reason.clone()),
1245            reasoning_content: None,
1246            model_name: None,
1247            tool_calls: None,
1248            observation: None,
1249            llm_call_count: Some(0),
1250        });
1251        WorkerJob {
1252            session,
1253            identity,
1254            class: EventClass::StopReason,
1255            payload: serde_json::json!({"reason": reason}),
1256            text: String::new(),
1257            analyze_loop: false,
1258            status: StatusId::Failure,
1259            stop_reason: Some(stop_reason),
1260            native_stop_reason: None,
1261            metrics: EventMetrics::default(),
1262            cost_usd_micros: 0,
1263            atif,
1264            response_marker: None,
1265            response_attempt: None,
1266        }
1267    }
1268
1269    pub(crate) fn authorize_session(
1270        &self,
1271        headers: &HeaderMap,
1272        session: &Session,
1273        required_scope: &str,
1274    ) -> Result<(), PipelineError> {
1275        let identity = self.resolve_identity(headers, Some(required_scope))?;
1276        validate_session_binding(session, session.workflow, &identity)?;
1277        session.refresh_identity(&identity);
1278        Ok(())
1279    }
1280
1281    pub(crate) fn resolve_identity(
1282        &self,
1283        headers: &HeaderMap,
1284        required_scope: Option<&str>,
1285    ) -> Result<AgentIdentity, PipelineError> {
1286        // Round-14 F1: reuse the round-13 duplicate-header refusal
1287        // pattern for `Authorization` on the identity hot path.
1288        // Previously `HeaderMap::get(AUTHORIZATION)` returned the
1289        // first value while the `PreparedRequest.client_authorization`
1290        // capture and header-smuggling proxies could observe a
1291        // merged `A, B` form — auth split-brain: the harness
1292        // authenticates as A while log aggregators / WAFs / OTLP
1293        // exporters attribute the request to B. Refuse the multi-
1294        // value case at ingress, symmetric with the X-AV-Session /
1295        // X-AV-Workflow guards.
1296        // Round-15 F3: RFC 7235 §2.1 declares the auth-scheme token
1297        // case-insensitive. Historically `strip_prefix("Bearer ")`
1298        // silently dropped `bearer eyJ...` / `BEARER eyJ...` /
1299        // `Bearer\teyJ...` to `None`, then the outer match arm
1300        // returned anonymous (when require_identity=false, the
1301        // shipped default) — the caller believed they had
1302        // authenticated while the audit trail attributed the
1303        // request to `anonymous`. That's the exact repudiation
1304        // vector the surrounding refuse-anonymous-with-token
1305        // guard was written to close. Now strip the scheme
1306        // case-insensitively.
1307        let bearer = single_header(headers, "authorization")?
1308            .and_then(|value| value.to_str().ok())
1309            .and_then(strip_bearer_scheme);
1310        match (bearer, &self.identity) {
1311            (Some(token), Some(validator)) => {
1312                let validated = validator.validate(token).map_err(|error| {
1313                    tracing::warn!(
1314                        error = %error,
1315                        "identity validation failed"
1316                    );
1317                    PipelineError::Unauthorized(classify_identity_error(&error).to_owned())
1318                })?;
1319                if self.config.enforce_identity_scopes {
1320                    if let Some(required) = required_scope {
1321                        if !scope_allows(&validated.claims.scopes, required) {
1322                            return Err(PipelineError::Unauthorized(format!(
1323                                "identity scope {required:?} is required"
1324                            )));
1325                        }
1326                    }
1327                }
1328                Ok(validated.agent_identity())
1329            }
1330            // Client presented a bearer but the validator is not
1331            // configured. Two legitimate cases and one attack surface:
1332            //  (a) `upstream_authorization_passthrough = true`: the
1333            //      client's bearer is the CREDENTIAL FOR THE UPSTREAM
1334            //      PROVIDER, not an identity claim for us. Accept
1335            //      anonymous locally so the request proceeds and the
1336            //      relay layer forwards the header. This is the
1337            //      documented dev-mode BYO-key posture.
1338            //  (b) Neither passthrough nor validator: the operator
1339            //      opted out of identity entirely. Any bearer is
1340            //      meaningless. Same accept-anonymous result — but
1341            //      warn once in tracing so the mismatch is visible.
1342            //  (c) A validator EXISTS but this arm ran because
1343            //      match fell through: unreachable because arm 1
1344            //      catches (Some, Some). No action.
1345            //
1346            // The attack case — a caller sends a scoped identity token
1347            // and we silently attribute the request as `anonymous`
1348            // producing a repudiation vector — was closed at commit
1349            // e56... by rejecting here. But that broke passthrough
1350            // mode. Restore acceptance when passthrough is on so the
1351            // BYO-key path works, and continue rejecting only when
1352            // NO passthrough is configured and identity was not
1353            // required (i.e. the client presenting a bearer to a
1354            // no-op harness).
1355            (Some(_), None) => {
1356                if self.config.upstream_authorization_passthrough {
1357                    Ok(AgentIdentity {
1358                        version: "dev".to_owned(),
1359                        charter: "anonymous".into(),
1360                        instance_uid: "anonymous".to_owned(),
1361                        ttl_remaining_s: None,
1362                    })
1363                } else {
1364                    Err(PipelineError::Unauthorized(
1365                        "bearer token presented but identity validator is not configured — \
1366                         refusing to silently record the request as anonymous (either \
1367                         configure identity_jwks_url / identity_hmac_secret_file, or set \
1368                         upstream_authorization_passthrough=true if the bearer is meant \
1369                         for the upstream provider)"
1370                            .to_owned(),
1371                    ))
1372                }
1373            }
1374            (None, _) if self.config.require_identity => {
1375                Err(PipelineError::Unauthorized("missing bearer token".to_owned()))
1376            }
1377            _ => Ok(AgentIdentity {
1378                version: "dev".to_owned(),
1379                charter: "anonymous".into(),
1380                instance_uid: "anonymous".to_owned(),
1381                ttl_remaining_s: None,
1382            }),
1383        }
1384    }
1385
1386    fn observe_stage(&self, stage: &str, started: Instant) {
1387        let elapsed = elapsed_us(started);
1388        self.metrics
1389            .histogram(
1390                &format!("av_stage_duration_seconds{{stage=\"{stage}\"}}"),
1391                "Harness stage latency",
1392            )
1393            .observe_us(elapsed);
1394        if (self.config.strict_stage_budget || truthy_env("AV_STRICT_BUDGET")) && elapsed > 2_000 {
1395            self.metrics
1396                .counter(
1397                    &format!("av_strict_budget_breaches_total{{stage=\"{stage}\"}}"),
1398                    "Middleware stages that exceeded the strict per-stage budget",
1399                )
1400                .inc();
1401            tracing::warn!(stage, elapsed_us = elapsed, "strict stage budget exceeded");
1402        }
1403    }
1404}
1405
1406fn truthy_env(name: &str) -> bool {
1407    match std::env::var(name) {
1408        Ok(value) => matches!(
1409            value.trim().to_ascii_lowercase().as_str(),
1410            "1" | "true" | "yes" | "on"
1411        ),
1412        Err(_) => false,
1413    }
1414}
1415
1416/// Map a `reqwest::Error` to a client-safe reason (CWE-209): the raw
1417/// `Display` includes the request URL, which leaks the operator-configured
1418/// upstream URL — potentially an internal hostname — to the caller. Return a
1419/// stable, non-identifying category instead; the detailed message is logged
1420/// server-side by the caller.
1421pub(crate) fn classify_upstream_error(error: &reqwest::Error) -> &'static str {
1422    if error.is_timeout() {
1423        "upstream timed out"
1424    } else if error.is_connect() {
1425        "upstream unreachable"
1426    } else if error.is_request() {
1427        "upstream request rejected"
1428    } else if error.is_body() || error.is_decode() {
1429        "upstream response malformed"
1430    } else if error.is_status() {
1431        "upstream returned error status"
1432    } else {
1433        "upstream forwarding failed"
1434    }
1435}
1436
1437/// Map an [`IdentityError`] to a client-safe reason (CWE-209).
1438///
1439/// The `Display` impl on `IdentityError` embeds attacker-influenced strings
1440/// (the presented `kid`, `iss`, and structural detail from the underlying
1441/// JWT parser) and distinguishes between at least sixteen distinct failure
1442/// modes. Returning that verbatim to the client turns each 401 response
1443/// into an *enumeration oracle* for the validator's configured JWKS and
1444/// issuer allowlist: an attacker can iterate candidate `kid`s and issuers
1445/// and read the response body to discover which values are registered
1446/// (`UnknownKid` vs `AlgorithmRejected` vs `Verification` vs `BadIssuer`
1447/// all produce distinguishable text).
1448///
1449/// We collapse every attacker-reachable variant to a single stable string.
1450/// The detailed cause is preserved server-side by the caller via
1451/// `tracing::warn!` and the failure-job audit chain, so operators keep
1452/// full diagnostic detail without exposing it to the network.
1453fn classify_identity_error(error: &av_identity::IdentityError) -> &'static str {
1454    match error {
1455        // Server-side misconfiguration is not attacker-reachable in the
1456        // normal request path (validator construction rejects a bad JWKS
1457        // at startup); report it distinctly so operator dashboards can
1458        // surface it if it ever appears.
1459        av_identity::IdentityError::Jwks(_) => "identity validator misconfigured",
1460        // Every other variant is at least partially attacker-influenced —
1461        // collapse to one opaque message. Any finer distinction leaks
1462        // configured `kid`s, issuers, or acceptable algorithms.
1463        _ => "identity validation failed",
1464    }
1465}
1466
1467/// Extract the single value for a control header, refusing duplicates.
1468///
1469/// `HeaderMap::get(name)` returns only the first entry when a header
1470/// appears multiple times. A client sending
1471/// `X-AV-Session: sessA` followed by `X-AV-Session: sessB` would then
1472/// have `sessA` used for state while an intermediary log-aggregator
1473/// might observe `sessA, sessB` (some proxies merge on the wire). The
1474/// resulting session-desync is hard to diagnose and is a header
1475/// smuggling primitive at boundaries where our proxy is behind
1476/// another one. Refuse the multi-value case at ingress rather than
1477/// silently accept the first.
1478pub(crate) fn single_header<'a>(
1479    headers: &'a HeaderMap,
1480    name: &'static str,
1481) -> Result<Option<&'a axum::http::HeaderValue>, PipelineError> {
1482    let mut iter = headers.get_all(name).into_iter();
1483    let first = iter.next();
1484    if iter.next().is_some() {
1485        return Err(PipelineError::BadRequest(format!(
1486            "request carries more than one {name} header; provide exactly one"
1487        )));
1488    }
1489    Ok(first)
1490}
1491
1492/// Strip the RFC 7235 §2.1 `Bearer` scheme from an Authorization header
1493/// value, case-insensitively (per the RFC), and allow one or more
1494/// whitespace characters (`SP` / `HTAB`) between the scheme token and
1495/// the credential (per `token1` production in §2.1).
1496///
1497/// Round-15 F3: `str::strip_prefix("Bearer ")` used to be the sole
1498/// parser. It missed `bearer`, `BEARER`, and (per the RFC's own
1499/// grammar) `Bearer\t...`, silently dropping to `None` and — when
1500/// `require_identity = false` — letting the request execute as
1501/// anonymous while the caller believed they had authenticated.
1502pub(crate) fn strip_bearer_scheme(value: &str) -> Option<&str> {
1503    let (scheme, rest) = value.split_at_checked(6)?;
1504    if !scheme.eq_ignore_ascii_case("Bearer") {
1505        return None;
1506    }
1507    let rest = rest.trim_start_matches([' ', '\t']);
1508    if rest.len() == value.len() - scheme.len() {
1509        // No whitespace at all after `Bearer` — the credential MUST
1510        // be separated per the ABNF (`token1 = 1*( SP / HTAB )
1511        // token`). Refuse `BearerXYZ` — that's a scheme other than
1512        // Bearer's credential, not a Bearer credential.
1513        return None;
1514    }
1515    if rest.is_empty() {
1516        // Empty credential.
1517        return None;
1518    }
1519    Some(rest)
1520}
1521
1522fn session_id(headers: &HeaderMap) -> Result<String, PipelineError> {
1523    match single_header(headers, SESSION_HEADER)? {
1524        Some(value) => {
1525            let value = value
1526                .to_str()
1527                .map_err(|_| PipelineError::BadRequest("X-AV-Session is not valid text".to_owned()))?;
1528            av_core::SessionId::parse(value)
1529                .map(|id| id.to_string())
1530                .map_err(|error| PipelineError::BadRequest(error.to_string()))
1531        }
1532        None => Ok(av_core::new_session_id().to_string()),
1533    }
1534}
1535
1536fn workflow(headers: &HeaderMap, default: &str) -> Result<Workflow, PipelineError> {
1537    let value = single_header(headers, WORKFLOW_HEADER)?
1538        .map(|header| {
1539            header
1540                .to_str()
1541                .map_err(|_| PipelineError::BadRequest("X-AV-Workflow is not valid text".to_owned()))
1542        })
1543        .transpose()?
1544        .unwrap_or(default);
1545    Workflow::parse(value).ok_or_else(|| {
1546        PipelineError::BadRequest(format!("X-AV-Workflow must be signed or unsigned, got {value:?}"))
1547    })
1548}
1549
1550fn last_message_text(payload: &Value) -> String {
1551    payload
1552        .get("messages")
1553        .and_then(Value::as_array)
1554        .and_then(|messages| messages.last())
1555        .and_then(|message| message.get("content"))
1556        .and_then(Value::as_str)
1557        .unwrap_or_default()
1558        .to_owned()
1559}
1560
1561/// Human-readable JSON type name for diagnostics — mirrors `typeof` in
1562/// JS. Used by [`atif_capture_from_request`] to say
1563/// `'messages' must be a JSON array, got object` rather than the
1564/// historical opaque `chat payload has no messages`.
1565fn json_type_name(value: &Value) -> &'static str {
1566    match value {
1567        Value::Null => "null",
1568        Value::Bool(_) => "boolean",
1569        Value::Number(_) => "number",
1570        Value::String(_) => "string",
1571        Value::Array(_) => "array",
1572        Value::Object(_) => "object",
1573    }
1574}
1575
1576fn atif_capture_from_request(payload: &Value) -> Result<AtifCapture, PipelineError> {
1577    // Split the two failure classes so support engineers can tell
1578    // "field missing" from "field is the wrong shape" — chasing a
1579    // phantom "no messages" ticket for a caller who typed
1580    // `"messages": {}` used to be the top diagnostic-quality complaint
1581    // (round-11 F7). The OpenAI Responses API also uses `input`
1582    // instead of `messages`; the missing-field message now points
1583    // directly at the correct fix.
1584    let messages_value = payload
1585        .get("messages")
1586        .ok_or_else(|| PipelineError::BadRequest("chat payload is missing 'messages'".to_owned()))?;
1587    let messages = messages_value.as_array().ok_or_else(|| {
1588        PipelineError::BadRequest(format!(
1589            "'messages' must be a JSON array, got {}",
1590            json_type_name(messages_value)
1591        ))
1592    })?;
1593    let message = messages
1594        .last()
1595        .ok_or_else(|| PipelineError::BadRequest("chat payload 'messages' is empty".to_owned()))?;
1596    let role = message.get("role").and_then(Value::as_str);
1597    let source = match role {
1598        Some("system" | "developer" | "tool") => av_atif::Source::System,
1599        Some("user") => av_atif::Source::User,
1600        Some("assistant") => av_atif::Source::Agent,
1601        Some(other) => {
1602            return Err(PipelineError::BadRequest(format!(
1603                "unsupported chat role {other:?}"
1604            )))
1605        }
1606        None => return Err(PipelineError::BadRequest("chat message has no role".to_owned())),
1607    };
1608    let content = message
1609        .get("content")
1610        .filter(|value| value.is_string() || value.is_array())
1611        .cloned()
1612        .unwrap_or_else(|| Value::String(String::new()));
1613    let tool_calls = message.get("tool_calls").and_then(Value::as_array).map(|calls| {
1614        calls
1615            .iter()
1616            .filter_map(|call| {
1617                let function = call.get("function")?;
1618                let arguments = function
1619                    .get("arguments")
1620                    .cloned()
1621                    .unwrap_or(Value::Object(Default::default()));
1622                let arguments = arguments
1623                    .as_str()
1624                    .and_then(|raw| serde_json::from_str(raw).ok())
1625                    .unwrap_or(arguments);
1626                Some(av_atif::ToolCall {
1627                    tool_call_id: call
1628                        .get("id")
1629                        .and_then(Value::as_str)
1630                        .map_or_else(av_core::new_event_uid, str::to_owned),
1631                    function_name: function.get("name")?.as_str()?.to_owned(),
1632                    arguments,
1633                    extra: None,
1634                })
1635            })
1636            .collect()
1637    });
1638    let observation = (role == Some("tool")).then(|| av_atif::Observation {
1639        results: vec![av_atif::ObservationResult {
1640            source_call_id: None,
1641            content: Some(content.clone()),
1642            subagent_trajectory_ref: None,
1643            extra: message
1644                .get("tool_call_id")
1645                .cloned()
1646                .map(|tool_call_id| serde_json::json!({"tool_call_id": tool_call_id})),
1647        }],
1648    });
1649    Ok(AtifCapture {
1650        source,
1651        message: content,
1652        reasoning_content: if source == av_atif::Source::Agent {
1653            message
1654                .get("reasoning_content")
1655                .and_then(Value::as_str)
1656                .map(str::to_owned)
1657        } else {
1658            None
1659        },
1660        model_name: if source == av_atif::Source::Agent {
1661            payload.get("model").and_then(Value::as_str).map(str::to_owned)
1662        } else {
1663            None
1664        },
1665        tool_calls: if source == av_atif::Source::Agent {
1666            tool_calls
1667        } else {
1668            None
1669        },
1670        observation,
1671        llm_call_count: (source == av_atif::Source::Agent).then_some(1),
1672    })
1673}
1674
1675fn validate_session_binding(
1676    session: &Session,
1677    workflow: Workflow,
1678    identity: &AgentIdentity,
1679) -> Result<(), PipelineError> {
1680    if session.workflow != workflow {
1681        return Err(PipelineError::BadRequest(
1682            "session workflow cannot change after open".to_owned(),
1683        ));
1684    }
1685    if session.identity.instance_uid != identity.instance_uid
1686        || session.identity.charter != identity.charter
1687        || session.identity.version != identity.version
1688    {
1689        return Err(PipelineError::Unauthorized(
1690            "session is bound to a different agent identity".to_owned(),
1691        ));
1692    }
1693    Ok(())
1694}
1695
1696fn scope_allows(scopes: &[String], required: &str) -> bool {
1697    scopes.iter().any(|scope| {
1698        scope == "*"
1699            || scope == required
1700            || scope
1701                .strip_suffix(":*")
1702                .is_some_and(|prefix| required.starts_with(&format!("{prefix}:")))
1703    })
1704}
1705
1706fn inject_corrective_message(payload: &mut Value) -> Result<(), PipelineError> {
1707    let messages = payload
1708        .get_mut("messages")
1709        .and_then(Value::as_array_mut)
1710        .ok_or_else(|| PipelineError::BadRequest("chat payload has no messages array".to_owned()))?;
1711    messages.push(serde_json::json!({
1712        "role": "system",
1713        "content": "AgentVisor AI detected a semantic loop. Stop repeating the previous approach, identify new evidence, and choose a materially different next action."
1714    }));
1715    Ok(())
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720    #![allow(
1721        clippy::expect_used,
1722        clippy::indexing_slicing,
1723        clippy::panic,
1724        clippy::unwrap_used
1725    )]
1726
1727    use super::*;
1728    use av_bridge::{BusError, PublishAck, StoredEvent};
1729    use av_receipts::Ed25519Signer;
1730    use av_sandbox::SandboxConfig;
1731    use av_state::InMemoryStore;
1732    use axum::http::HeaderValue;
1733    use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
1734
1735    struct NullBus;
1736
1737    struct BlockingSink {
1738        entered: AtomicBool,
1739        release: tokio::sync::Notify,
1740    }
1741
1742    impl VectorSink for BlockingSink {
1743        fn record<'a>(
1744            &'a self,
1745            _session_id: &'a str,
1746            _vector: &'a [f32],
1747        ) -> av_loopdetect::VectorSinkFuture<'a> {
1748            Box::pin(async move {
1749                self.entered.store(true, AtomicOrdering::Release);
1750                self.release.notified().await;
1751                Ok(())
1752            })
1753        }
1754    }
1755
1756    impl EventBus for NullBus {
1757        fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
1758            Ok(PublishAck {
1759                topic: topic.to_owned(),
1760                partition: 0,
1761                offset: 0,
1762            })
1763        }
1764
1765        fn fetch(
1766            &self,
1767            _topic: &str,
1768            _partition: u32,
1769            _offset: u64,
1770            _max: usize,
1771        ) -> Result<Vec<StoredEvent>, BusError> {
1772            Ok(Vec::new())
1773        }
1774
1775        fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
1776            Ok(1)
1777        }
1778
1779        fn topics(&self) -> Vec<String> {
1780            EventClass::all()
1781                .iter()
1782                .map(|class| class.topic().to_owned())
1783                .collect()
1784        }
1785    }
1786
1787    fn state(mut config: HarnessConfig) -> AppState {
1788        config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
1789        AppState::new(
1790            config,
1791            Arc::new(InMemoryStore::new()),
1792            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
1793            Arc::new(NullBus),
1794            None,
1795            Arc::new(Ed25519Signer::from_seed(&[9; 32])),
1796        )
1797        .unwrap()
1798    }
1799
1800    fn payload() -> Value {
1801        serde_json::json!({
1802            "model": "test",
1803            "messages": [{"role": "user", "content": "hello"}],
1804        })
1805    }
1806
1807    async fn trip_loop(state: &AppState, headers: &HeaderMap, repeated: &Value) {
1808        for expected in 1..=4u64 {
1809            state.prepare_chat(headers, repeated.clone()).unwrap();
1810            let session = state.sessions.get("loop-session").unwrap();
1811            // Generous budget: the chain append rides an async worker job and
1812            // must survive heavily loaded parallel test runs.
1813            tokio::time::timeout(std::time::Duration::from_secs(10), async {
1814                while session.chain.lock().count() < expected {
1815                    tokio::task::yield_now().await;
1816                }
1817            })
1818            .await
1819            .unwrap();
1820        }
1821    }
1822
1823    #[tokio::test]
1824    async fn prepare_binds_session_and_stays_below_hot_path_budget() {
1825        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1826        let state = state(config);
1827        let mut headers = HeaderMap::new();
1828        headers.insert(SESSION_HEADER, HeaderValue::from_static("session-1"));
1829
1830        let prepared = state.prepare_chat(&headers, payload()).unwrap();
1831        assert_eq!(prepared.session.id, "session-1");
1832        // Debug-build ceiling: 100ms is 3 orders of magnitude above the release
1833        // SLA (~33us p95); a regression an order of magnitude worse still trips
1834        // this. The strict perf gate lives in the SLA bench suite.
1835        assert!(
1836            prepared.middleware_us < 100_000,
1837            "middleware took {}us",
1838            prepared.middleware_us
1839        );
1840        assert!(state.metrics.render().contains("av_stage_duration_seconds"));
1841    }
1842
1843    #[tokio::test]
1844    async fn missing_identity_fails_closed_when_required() {
1845        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1846        config.require_identity = true;
1847        let error = match state(config).prepare_chat(&HeaderMap::new(), payload()) {
1848            Ok(_) => panic!("missing identity was accepted"),
1849            Err(error) => error,
1850        };
1851        assert!(matches!(error, PipelineError::Unauthorized(_)));
1852    }
1853
1854    #[tokio::test]
1855    async fn token_quota_blocks_before_dispatch() {
1856        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1857        config.budget.max_tokens = Some(1);
1858        let state = state(config);
1859        let mut headers = HeaderMap::new();
1860        headers.insert(SESSION_HEADER, HeaderValue::from_static("quota-failure"));
1861        let error = match state.prepare_chat(&headers, payload()) {
1862            Ok(_) => panic!("over-budget request was accepted"),
1863            Err(error) => error,
1864        };
1865        assert!(matches!(error, PipelineError::Blocked(_)));
1866        assert_eq!(state.sessions.len(), 1);
1867        let session = state.sessions.get("quota-failure").unwrap();
1868        tokio::time::timeout(std::time::Duration::from_secs(1), async {
1869            while session.atif.lock().is_empty() {
1870                tokio::task::yield_now().await;
1871            }
1872        })
1873        .await
1874        .unwrap();
1875    }
1876
1877    #[tokio::test]
1878    async fn invalid_workflow_is_rejected() {
1879        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1880        let state = state(config);
1881        let mut headers = HeaderMap::new();
1882        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("sometimes"));
1883        assert!(matches!(
1884            state.prepare_chat(&headers, payload()),
1885            Err(PipelineError::BadRequest(_))
1886        ));
1887    }
1888
1889    #[tokio::test]
1890    async fn open_session_cannot_change_workflow() {
1891        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1892        let state = state(config);
1893        let mut headers = HeaderMap::new();
1894        headers.insert(SESSION_HEADER, HeaderValue::from_static("bound-session"));
1895        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
1896        state.prepare_chat(&headers, payload()).unwrap();
1897        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
1898        assert!(matches!(
1899            state.prepare_chat(&headers, payload()),
1900            Err(PipelineError::BadRequest(_))
1901        ));
1902    }
1903
1904    #[tokio::test]
1905    async fn asynchronous_loop_verdict_blocks_the_next_request() {
1906        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1907        config.breaker.min_tokens = 0;
1908        let state = state(config);
1909        let mut headers = HeaderMap::new();
1910        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
1911        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
1912        let repeated = serde_json::json!({
1913            "model": "test",
1914            "messages": [{
1915                "role": "assistant",
1916                "content": "I should check the database again for pending orders"
1917            }]
1918        });
1919        trip_loop(&state, &headers, &repeated).await;
1920        assert!(matches!(
1921            state.prepare_chat(&headers, repeated),
1922            Err(PipelineError::Blocked(_))
1923        ));
1924    }
1925
1926    #[tokio::test]
1927    async fn loop_inject_action_adds_correction_and_resets_breaker() {
1928        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1929        config.breaker.min_tokens = 0;
1930        config.breaker.action = BreakerAction::Inject;
1931        let state = state(config);
1932        let mut headers = HeaderMap::new();
1933        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
1934        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
1935        let repeated = serde_json::json!({
1936            "messages": [{"role": "assistant", "content": "repeat the database query"}]
1937        });
1938        trip_loop(&state, &headers, &repeated).await;
1939        let prepared = state.prepare_chat(&headers, repeated).unwrap();
1940        let correction = prepared
1941            .payload
1942            .get("messages")
1943            .and_then(Value::as_array)
1944            .and_then(|messages| messages.last())
1945            .and_then(|message| message.get("content"))
1946            .and_then(Value::as_str)
1947            .unwrap();
1948        assert!(correction.contains("semantic loop"));
1949        assert_eq!(prepared.session.loop_state.state(), BreakerState::Closed);
1950    }
1951
1952    #[tokio::test]
1953    async fn loop_abort_action_returns_abort_error() {
1954        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1955        config.breaker.min_tokens = 0;
1956        config.breaker.action = BreakerAction::Abort;
1957        let state = state(config);
1958        let mut headers = HeaderMap::new();
1959        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
1960        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
1961        let repeated = serde_json::json!({
1962            "messages": [{"role": "assistant", "content": "repeat the database query"}]
1963        });
1964        trip_loop(&state, &headers, &repeated).await;
1965        assert!(matches!(
1966            state.prepare_chat(&headers, repeated),
1967            Err(PipelineError::Abort(_))
1968        ));
1969    }
1970
1971    /// A breaker trip used to replace the job's Compression class with
1972    /// StopReason *before* the accounting decisions ran, so the tripped
1973    /// admission's prompt tokens vanished from the session totals (and the
1974    /// journal record) — receipts undercounted exactly the runaway sessions
1975    /// the breaker exists to attest. Accounting must key on the submitted
1976    /// class, not the swapped one.
1977    #[tokio::test]
1978    async fn breaker_trip_does_not_drop_the_admissions_prompt_token_accounting() {
1979        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
1980        config.breaker.min_tokens = 0;
1981        let state = state(config);
1982        let mut headers = HeaderMap::new();
1983        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
1984        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
1985        let repeated = serde_json::json!({
1986            "model": "test",
1987            "messages": [{
1988                "role": "assistant",
1989                "content": "I should check the database again for pending orders"
1990            }]
1991        });
1992        // Four identical admissions, tracking chain growth like `trip_loop`;
1993        // the breaker trips on one of the later worker jobs. Capture the
1994        // per-admission token amount after the first job lands.
1995        let mut per_request = 0u64;
1996        for expected in 1..=4u64 {
1997            state.prepare_chat(&headers, repeated.clone()).unwrap();
1998            let session = state.sessions.get("loop-session").unwrap();
1999            tokio::time::timeout(std::time::Duration::from_secs(10), async {
2000                while session.chain.lock().count() < expected {
2001                    tokio::task::yield_now().await;
2002                }
2003            })
2004            .await
2005            .unwrap();
2006            if expected == 1 {
2007                tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
2008                    .await
2009                    .unwrap();
2010                per_request = session
2011                    .totals
2012                    .prompt_tokens
2013                    .load(std::sync::atomic::Ordering::Acquire);
2014                assert!(per_request > 0, "admission must account prompt tokens");
2015            }
2016        }
2017        let session = state.sessions.get("loop-session").unwrap();
2018        tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
2019            .await
2020            .unwrap();
2021        assert_eq!(
2022            session.loop_state.state(),
2023            BreakerState::Open,
2024            "precondition: the breaker must have tripped during the four admissions",
2025        );
2026        assert_eq!(
2027            session
2028                .totals
2029                .prompt_tokens
2030                .load(std::sync::atomic::Ordering::Acquire),
2031            per_request * 4,
2032            "every admission's prompt tokens must be accounted, including the one whose \
2033             worker job carried the breaker trip",
2034        );
2035    }
2036
2037    /// Budget counters are dead weight once a session is sealed (admission
2038    /// rejects closed sessions before any quota check); finalization must
2039    /// clear them or the in-memory state store grows by a few cells per
2040    /// client-chosen session id forever.
2041    #[tokio::test]
2042    async fn finalization_clears_the_sessions_budget_counters() {
2043        let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
2044        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2045        config.budget.max_tokens = Some(1_000_000);
2046        config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
2047        let state = AppState::new(
2048            config,
2049            Arc::clone(&store),
2050            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
2051            Arc::new(NullBus),
2052            None,
2053            Arc::new(Ed25519Signer::from_seed(&[9; 32])),
2054        )
2055        .unwrap();
2056        let mut headers = HeaderMap::new();
2057        headers.insert(SESSION_HEADER, HeaderValue::from_static("budget-cleanup"));
2058        state.prepare_chat(&headers, payload()).unwrap();
2059        let tokens_key = format!(
2060            "{}tokens",
2061            av_state::ActionBudget::session_prefix("budget-cleanup")
2062        );
2063        assert!(
2064            store.get(&tokens_key).unwrap() > 0,
2065            "precondition: admission must have spent from the token budget",
2066        );
2067        let session = state.sessions.get("budget-cleanup").unwrap();
2068        session.wait_for_worker_jobs().await;
2069        state
2070            .finalizer
2071            .close_session(session, StopReason::SessionClosed)
2072            .await
2073            .unwrap();
2074        assert_eq!(
2075            store.get(&tokens_key).unwrap(),
2076            0,
2077            "finalization must remove the sealed session's budget counters",
2078        );
2079    }
2080
2081    #[tokio::test]
2082    async fn consequential_tools_require_signed_workflows() {
2083        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2084        let state = state(config);
2085        let raw = serde_json::to_vec(&serde_json::json!({
2086            "jsonrpc": "2.0",
2087            "id": 1,
2088            "method": "tools/call",
2089            "params": {"name": "db_write", "arguments": {}}
2090        }))
2091        .unwrap();
2092        let mut unsigned = HeaderMap::new();
2093        unsigned.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-write"));
2094        assert!(matches!(
2095            state.intercept_tool(&unsigned, &raw).unwrap(),
2096            ToolVerdict::Blocked { stage: "policy", .. }
2097        ));
2098
2099        let mut signed = HeaderMap::new();
2100        signed.insert(SESSION_HEADER, HeaderValue::from_static("signed-write"));
2101        signed.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
2102        assert!(state.intercept_tool(&signed, &raw).unwrap().is_allowed());
2103    }
2104
2105    /// `intercept_tool_durable` used to re-derive the session id from headers
2106    /// AFTER the verdict. For a header-less request `session_id()` mints a
2107    /// fresh random id on every call, so the post-verdict lookup targeted a
2108    /// session that never existed and the call always failed with "tool
2109    /// session disappeared" — after the verdict had already been computed and
2110    /// audited under the real (generated) session.
2111    #[tokio::test]
2112    async fn intercept_tool_durable_works_without_a_session_header() {
2113        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2114        let state = state(config);
2115        let raw = serde_json::to_vec(&serde_json::json!({
2116            "jsonrpc": "2.0",
2117            "id": 1,
2118            "method": "tools/call",
2119            "params": {"name": "safe_tool", "arguments": {}}
2120        }))
2121        .unwrap();
2122        let verdict = state
2123            .intercept_tool_durable(&HeaderMap::new(), &raw)
2124            .await
2125            .unwrap();
2126        assert!(
2127            verdict.is_allowed(),
2128            "durable interception must await the verdict's own session, not a re-derived id",
2129        );
2130    }
2131
2132    /// When `prepare_chat_durable` refuses a request AFTER admission (the
2133    /// request's own loop analysis opened the breaker during the audit wait),
2134    /// it must submit the terminal failure record for the response attempt
2135    /// journaled at admission. Otherwise the active journal ends with a
2136    /// dangling non-terminal attempt and a later crash-recovery scan
2137    /// quarantines the session over a request the client already saw fail.
2138    #[tokio::test]
2139    async fn durable_breaker_refusal_journals_a_terminal_response_attempt() {
2140        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2141        config.breaker.min_tokens = 0;
2142        let state = state(config);
2143        let mut headers = HeaderMap::new();
2144        headers.insert(SESSION_HEADER, HeaderValue::from_static("durable-loop"));
2145        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
2146        let repeated = serde_json::json!({
2147            "model": "test",
2148            "messages": [{
2149                "role": "assistant",
2150                "content": "I should check the database again for pending orders"
2151            }],
2152        });
2153        // Attempts the test itself abandons after a successful prepare: they
2154        // are expected to stay non-terminal because no forward happens here.
2155        let mut abandoned_by_test = std::collections::HashSet::new();
2156        let mut refusal = None;
2157        for _ in 0..8 {
2158            match state.prepare_chat_durable(&headers, repeated.clone()).await {
2159                Ok(prepared) => {
2160                    abandoned_by_test.insert(prepared.response_attempt_id.clone());
2161                }
2162                Err(error) => {
2163                    refusal = Some(error);
2164                    break;
2165                }
2166            }
2167        }
2168        assert!(
2169            matches!(refusal, Some(PipelineError::Blocked(_))),
2170            "the breaker must refuse during the audit wait, got {refusal:?}",
2171        );
2172        assert!(
2173            !abandoned_by_test.is_empty(),
2174            "at least one prepare must succeed first"
2175        );
2176        let session = state.sessions.get("durable-loop").unwrap();
2177        session.wait_for_worker_jobs().await;
2178
2179        let digest = av_core::digest::sha256_hex("durable-loop".as_bytes());
2180        let stem = digest.get(..32).unwrap();
2181        let journal_path =
2182            std::path::Path::new(&state.config.atif_spool_dir).join(format!("{stem}.events.ndjson"));
2183        let journal = std::fs::read_to_string(&journal_path).unwrap();
2184        let mut dangling = std::collections::HashSet::new();
2185        for (index, line) in journal.lines().enumerate() {
2186            let record: crate::worker::ActiveJournalRecord = crate::journal::open(
2187                &state.journal_key,
2188                "durable-loop:active",
2189                index as u64,
2190                line.as_bytes(),
2191            )
2192            .unwrap();
2193            if let Some(attempt) = record.response_attempt {
2194                if attempt.terminal {
2195                    assert!(
2196                        dangling.remove(&attempt.id),
2197                        "terminal record for attempt {} has no admission record",
2198                        attempt.id,
2199                    );
2200                } else {
2201                    dangling.insert(attempt.id);
2202                }
2203            }
2204        }
2205        assert_eq!(
2206            dangling, abandoned_by_test,
2207            "the refused request's response attempt must be terminated in the journal; \
2208             only attempts the test itself dropped may remain open",
2209        );
2210    }
2211
2212    /// A session that only routes MCP tool calls (no chat completions) must
2213    /// still be considered active by `idle_sessions` — otherwise the
2214    /// reconciler tick force-closes tool-only sessions after
2215    /// `session_idle_close_s` and every subsequent tool call fails with
2216    /// "session is already closed". `prepare_chat` refreshes
2217    /// `last_activity_ms` via `touch()`; `intercept_tool` did not.
2218    #[tokio::test]
2219    async fn intercept_tool_refreshes_session_activity() {
2220        use std::sync::atomic::Ordering as AtomicOrdering;
2221
2222        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2223        let state = state(config);
2224        let raw = serde_json::to_vec(&serde_json::json!({
2225            "jsonrpc": "2.0",
2226            "id": 1,
2227            "method": "tools/call",
2228            "params": {"name": "safe_tool", "arguments": {}}
2229        }))
2230        .unwrap();
2231        let mut headers = HeaderMap::new();
2232        headers.insert(SESSION_HEADER, HeaderValue::from_static("tool-only-session"));
2233        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
2234
2235        state.intercept_tool(&headers, &raw).unwrap();
2236        let session = state.sessions.get("tool-only-session").unwrap();
2237        // Backdate the session to before the epoch's first second — this stands
2238        // in for a real session that has been quiet long enough to hit the
2239        // idle sweeper's cutoff.
2240        session.last_activity_ms.store(0, AtomicOrdering::Release);
2241
2242        state.intercept_tool(&headers, &raw).unwrap();
2243
2244        let refreshed = session.last_activity_ms.load(AtomicOrdering::Acquire);
2245        assert!(
2246            refreshed > 0,
2247            "intercept_tool must refresh last_activity_ms so a tool-only session is not force-closed by the idle sweeper — got {refreshed}",
2248        );
2249    }
2250
2251    /// `intercept_tool` used to call `sandbox.check` unconditionally and then
2252    /// post-hoc override an `Allowed` verdict to `Blocked` when the workflow
2253    /// gate vetoes a consequential tool on an unsigned session. But
2254    /// `sandbox.check`'s budget gate (`try_spend_many`) spends before the
2255    /// override runs, so a legitimate client asking for a consequential tool
2256    /// on the wrong workflow got their `max_total_tool_calls` counter
2257    /// decremented for a call the sandbox never actually authorized. Once the
2258    /// client upgraded to a signed session to make the call for real, they
2259    /// hit the cap short of the number they were configured for.
2260    #[tokio::test]
2261    async fn unsigned_consequential_veto_does_not_spend_budget() {
2262        let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
2263        let sandbox_config = SandboxConfig {
2264            budget: av_state::BudgetSpec {
2265                max_total_tool_calls: Some(5),
2266                ..av_state::BudgetSpec::default()
2267            },
2268            ..SandboxConfig::default()
2269        };
2270        let sandbox = Arc::new(Sandbox::new(sandbox_config, Vec::new()).unwrap());
2271        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2272        config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
2273        let state = AppState::new(
2274            config,
2275            Arc::clone(&store),
2276            sandbox,
2277            Arc::new(NullBus),
2278            None,
2279            Arc::new(Ed25519Signer::from_seed(&[9; 32])),
2280        )
2281        .unwrap();
2282
2283        let raw = serde_json::to_vec(&serde_json::json!({
2284            "jsonrpc": "2.0",
2285            "id": 1,
2286            "method": "tools/call",
2287            "params": {"name": "db_write", "arguments": {}}
2288        }))
2289        .unwrap();
2290        let mut headers = HeaderMap::new();
2291        headers.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-consequential"));
2292        // Default workflow is unsigned; no explicit x-av-workflow header.
2293
2294        let verdict = state.intercept_tool(&headers, &raw).unwrap();
2295        assert!(
2296            matches!(verdict, ToolVerdict::Blocked { stage: "policy", .. }),
2297            "consequential tool on unsigned workflow must block at the policy gate; got {verdict:?}",
2298        );
2299
2300        let session_digest = av_core::digest::sha256_hex("unsigned-consequential".as_bytes());
2301        let key = format!("budget:{{{}}}:total_calls", session_digest.get(..32).unwrap());
2302        let count = store.get(&key).unwrap();
2303        assert_eq!(
2304            count, 0,
2305            "sandbox.check must not spend the budget for a call the workflow gate vetoes — otherwise a legitimate client that upgrades to signed and retries hits the cap short of the configured limit",
2306        );
2307    }
2308
2309    #[tokio::test]
2310    async fn full_capture_queue_fails_closed_before_upstream() {
2311        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2312        // The fused permit split (worker_capacity + response_capacity)
2313        // gives distinct exhaustion counters but keeps the *effective*
2314        // total admission the same as before, because response
2315        // capture doesn't hold an mpsc slot up front — only the
2316        // response semaphore. So with worker_channel_capacity=2 the
2317        // first request consumes 1 worker semaphore + 1 response
2318        // semaphore + 1 mpsc slot; the second request consumes the
2319        // remaining worker semaphore + response semaphore + mpsc slot;
2320        // the third would fail. Set to 1 for a clean single-request
2321        // saturation.
2322        config.worker_channel_capacity = 1;
2323        config.breaker.min_tokens = u64::MAX;
2324        let sink = Arc::new(BlockingSink {
2325            entered: AtomicBool::new(false),
2326            release: tokio::sync::Notify::new(),
2327        });
2328        let state = AppState::new_with_backends(
2329            config,
2330            Arc::new(InMemoryStore::new()),
2331            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
2332            Arc::new(NullBus),
2333            None,
2334            Arc::new(Ed25519Signer::from_seed(&[10; 32])),
2335            Arc::new(HashEmbedder::default()),
2336            sink.clone(),
2337        )
2338        .unwrap();
2339        let mut headers = HeaderMap::new();
2340        headers.insert(SESSION_HEADER, HeaderValue::from_static("overload"));
2341        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
2342        let mut agent_payload = payload();
2343        agent_payload["messages"][0]["role"] = Value::String("assistant".to_owned());
2344        state.prepare_chat(&headers, agent_payload.clone()).unwrap();
2345        tokio::time::timeout(std::time::Duration::from_secs(1), async {
2346            while !sink.entered.load(AtomicOrdering::Acquire) {
2347                tokio::task::yield_now().await;
2348            }
2349        })
2350        .await
2351        .unwrap();
2352        assert!(matches!(
2353            state.prepare_chat(&headers, agent_payload),
2354            Err(PipelineError::Unavailable(_))
2355        ));
2356        sink.release.notify_waiters();
2357    }
2358
2359    #[test]
2360    fn identity_scope_matching_supports_exact_and_namespace_wildcards() {
2361        assert!(scope_allows(&["chat:write".into()], "chat:write"));
2362        assert!(scope_allows(&["tool:*".into()], "tool:db_write"));
2363        assert!(scope_allows(&["*".into()], "session:promote"));
2364        assert!(!scope_allows(&["chat:read".into()], "chat:write"));
2365        assert!(!scope_allows(&["tooling:*".into()], "tool:db_write"));
2366    }
2367
2368    #[test]
2369    fn developer_and_tool_roles_map_to_valid_system_steps() {
2370        for (role, expects_observation) in [("developer", false), ("tool", true)] {
2371            let capture = atif_capture_from_request(&serde_json::json!({
2372                "messages": [{
2373                    "role": role,
2374                    "content": "context",
2375                    "tool_call_id": "call-1"
2376                }]
2377            }))
2378            .unwrap();
2379            assert_eq!(capture.source, av_atif::Source::System);
2380            assert_eq!(capture.observation.is_some(), expects_observation);
2381            assert!(capture.model_name.is_none());
2382        }
2383    }
2384
2385    /// Round-11 F7: `messages` field with the wrong type used to return
2386    /// the same "chat payload has no messages" as an absent field,
2387    /// steering support tickets at a phantom bug. Verify each class
2388    /// now returns a discriminating error message.
2389    #[test]
2390    fn atif_capture_rejects_non_array_messages_with_precise_diagnostics() {
2391        fn bad_request_message(payload: serde_json::Value) -> String {
2392            match atif_capture_from_request(&payload) {
2393                Ok(_) => panic!("expected BadRequest, got Ok"),
2394                Err(PipelineError::BadRequest(msg)) => msg,
2395                Err(other) => panic!("expected BadRequest, got {other:?}"),
2396            }
2397        }
2398        // (1) Field entirely missing (e.g., OpenAI Responses API caller
2399        //     who used `input` instead of `messages`).
2400        let msg = bad_request_message(serde_json::json!({
2401            "input": [{ "role": "user", "content": "hi" }]
2402        }));
2403        assert!(msg.contains("missing 'messages'"), "got {msg}");
2404        // (2) Field present but wrong JSON shape — object.
2405        let msg = bad_request_message(serde_json::json!({
2406            "messages": { "0": { "role": "user", "content": "hi" } }
2407        }));
2408        assert!(msg.contains("must be a JSON array"), "got {msg}");
2409        assert!(msg.contains("object"), "got {msg}");
2410        // (3) Field present, is an array, but empty.
2411        let msg = bad_request_message(serde_json::json!({ "messages": [] }));
2412        assert!(msg.contains("empty"), "got {msg}");
2413    }
2414
2415    /// Round-13: multiple X-AV-Session (or X-AV-Workflow) headers on
2416    /// a single request must be refused. HeaderMap::get() returns
2417    /// only the first, so an intermediary that merges duplicates on
2418    /// the wire and downstream code that reads them separately can
2419    /// disagree on which session id was in effect — a header
2420    /// smuggling desync. Refuse loudly at ingress.
2421    #[test]
2422    fn duplicate_x_av_session_header_is_refused() {
2423        let mut headers = HeaderMap::new();
2424        headers.append(SESSION_HEADER, HeaderValue::from_static("sessA"));
2425        headers.append(SESSION_HEADER, HeaderValue::from_static("sessB"));
2426        match session_id(&headers) {
2427            Ok(id) => panic!("expected duplicate-header refusal, got session_id {id:?}"),
2428            Err(PipelineError::BadRequest(msg)) => {
2429                assert!(msg.contains("more than one"), "got {msg}");
2430                assert!(msg.contains("x-av-session"), "got {msg}");
2431            }
2432            Err(other) => panic!("expected BadRequest, got {other:?}"),
2433        }
2434    }
2435
2436    #[test]
2437    fn duplicate_x_av_workflow_header_is_refused() {
2438        let mut headers = HeaderMap::new();
2439        headers.append(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
2440        headers.append(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
2441        match workflow(&headers, "signed") {
2442            Ok(w) => panic!("expected duplicate-header refusal, got workflow {w:?}"),
2443            Err(PipelineError::BadRequest(msg)) => {
2444                assert!(msg.contains("more than one"), "got {msg}");
2445                assert!(msg.contains("x-av-workflow"), "got {msg}");
2446            }
2447            Err(other) => panic!("expected BadRequest, got {other:?}"),
2448        }
2449    }
2450
2451    /// Single X-AV-Session must continue to work — this is the
2452    /// happy-path regression guard for the duplicate-header refusal.
2453    #[test]
2454    fn single_x_av_session_header_still_flows() {
2455        let mut headers = HeaderMap::new();
2456        headers.insert(SESSION_HEADER, HeaderValue::from_static("only-one"));
2457        assert_eq!(session_id(&headers).unwrap(), "only-one");
2458    }
2459
2460    /// Round-14 F1: identity hot path (`resolve_identity`) must refuse
2461    /// duplicate `Authorization` headers, symmetric with X-AV-Session.
2462    /// Previously `HeaderMap::get(AUTHORIZATION)` returned the first
2463    /// value only — the harness would authenticate as `A` while
2464    /// log aggregators / WAFs seeing a merged `A, B` form would
2465    /// attribute the request to `B`. This is the identity split-brain
2466    /// round-13 tried to close for session headers.
2467    #[tokio::test]
2468    async fn resolve_identity_refuses_duplicate_authorization_header() {
2469        let state = null_state();
2470        let mut headers = HeaderMap::new();
2471        headers.append(
2472            axum::http::header::AUTHORIZATION,
2473            HeaderValue::from_static("Bearer aaaa"),
2474        );
2475        headers.append(
2476            axum::http::header::AUTHORIZATION,
2477            HeaderValue::from_static("Bearer bbbb"),
2478        );
2479        match state.resolve_identity(&headers, None) {
2480            Ok(_) => panic!("expected duplicate-header refusal, got Ok"),
2481            Err(PipelineError::BadRequest(msg)) => {
2482                assert!(msg.contains("more than one"), "got {msg}");
2483                assert!(msg.contains("authorization"), "got {msg}");
2484            }
2485            Err(other) => panic!("expected BadRequest, got {other:?}"),
2486        }
2487    }
2488
2489    /// Single-value Authorization still flows through (happy-path
2490    /// regression guard for the dedup refusal).
2491    #[tokio::test]
2492    async fn resolve_identity_accepts_single_authorization_header() {
2493        let state = null_state();
2494        let mut headers = HeaderMap::new();
2495        headers.insert(
2496            axum::http::header::AUTHORIZATION,
2497            HeaderValue::from_static("Bearer aaaa"),
2498        );
2499        // No validator configured → refuse-401 fires per round-10 F3.
2500        // The important assertion is that we did NOT get a
2501        // BadRequest("more than one") on a single header.
2502        let outcome = state.resolve_identity(&headers, None);
2503        assert!(
2504            !matches!(&outcome, Err(PipelineError::BadRequest(msg)) if msg.contains("more than one")),
2505            "single Authorization header must not be refused as duplicate; got {outcome:?}"
2506        );
2507    }
2508
2509    /// Round-15 F3: RFC 7235 §2.1 auth-scheme is case-insensitive.
2510    /// A caller sending `Authorization: bearer eyJ...` or `BEARER`
2511    /// used to be silently downgraded to anonymous (when
2512    /// require_identity=false, the shipped default) — repudiation
2513    /// class. Verify each case now parses to the same credential.
2514    #[test]
2515    fn strip_bearer_scheme_matches_case_insensitively() {
2516        for scheme in ["Bearer aaaa", "bearer aaaa", "BEARER aaaa", "BeArEr aaaa"] {
2517            assert_eq!(
2518                strip_bearer_scheme(scheme),
2519                Some("aaaa"),
2520                "scheme {scheme:?} did not parse to `aaaa`"
2521            );
2522        }
2523        // Tab between scheme and credential (per the RFC's `SP / HTAB` grammar).
2524        assert_eq!(strip_bearer_scheme("Bearer\taaaa"), Some("aaaa"));
2525        // Multiple spaces are allowed.
2526        assert_eq!(strip_bearer_scheme("Bearer   aaaa"), Some("aaaa"));
2527        // Missing whitespace or empty credential — refused.
2528        assert_eq!(strip_bearer_scheme("BearerXYZ"), None);
2529        assert_eq!(strip_bearer_scheme("Bearer "), None);
2530        assert_eq!(strip_bearer_scheme("Bearer"), None);
2531        // Different scheme — refused.
2532        assert_eq!(strip_bearer_scheme("Basic ZmY6bGFyZQ=="), None);
2533        // Too short to hold the scheme name — refused.
2534        assert_eq!(strip_bearer_scheme(""), None);
2535    }
2536
2537    fn null_state() -> AppState {
2538        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2539        config.require_identity = false;
2540        let sandbox = Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap();
2541        let store = Arc::new(InMemoryStore::new());
2542        let bridge: Arc<dyn EventBus> = Arc::new(NullBus);
2543        let signer: Arc<dyn Signer> = Arc::new(Ed25519Signer::generate());
2544        let embedder = Arc::new(HashEmbedder::default());
2545        let vector_sink: Arc<dyn VectorSink> = Arc::new(NoopVectorSink);
2546        AppState::new_with_backends(
2547            config,
2548            store,
2549            Arc::new(sandbox),
2550            bridge,
2551            None,
2552            signer,
2553            embedder,
2554            vector_sink,
2555        )
2556        .unwrap()
2557    }
2558
2559    fn scoped_token(scopes: &[&str]) -> String {
2560        let now = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
2561        let claims = av_identity::NhiClaims {
2562            sub: "agent:test".into(),
2563            iss: "https://idp.example".into(),
2564            aud: "agentvisor-ai".into(),
2565            iat: now,
2566            nbf: None,
2567            exp: now + 600,
2568            jti: av_core::new_event_uid(),
2569            instance_uid: "scoped-instance".into(),
2570            charter: "scoped-charter".into(),
2571            version: "1".into(),
2572            scopes: scopes.iter().map(|scope| (*scope).to_owned()).collect(),
2573            parent_token: None,
2574        };
2575        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
2576        header.kid = Some("scope-key".into());
2577        jsonwebtoken::encode(
2578            &header,
2579            &claims,
2580            &jsonwebtoken::EncodingKey::from_secret(b"scope-secret"),
2581        )
2582        .unwrap()
2583    }
2584
2585    fn scoped_state() -> AppState {
2586        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2587        config.require_identity = true;
2588        config.enforce_identity_scopes = true;
2589        let validator = av_identity::IdentityValidator::new("agentvisor-ai");
2590        validator
2591            .add_key(
2592                "scope-key",
2593                av_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
2594            )
2595            .unwrap();
2596        AppState::new(
2597            config,
2598            Arc::new(InMemoryStore::new()),
2599            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
2600            Arc::new(NullBus),
2601            Some(Arc::new(validator)),
2602            Arc::new(Ed25519Signer::from_seed(&[12; 32])),
2603        )
2604        .unwrap()
2605    }
2606
2607    #[tokio::test]
2608    async fn real_jwt_scopes_gate_chat_and_lifecycle_operations() {
2609        let state = scoped_state();
2610        let mut headers = HeaderMap::new();
2611        headers.insert(SESSION_HEADER, HeaderValue::from_static("scoped-session"));
2612        headers.insert(
2613            axum::http::header::AUTHORIZATION,
2614            HeaderValue::from_str(&format!("Bearer {}", scoped_token(&["chat:write"]))).unwrap(),
2615        );
2616        let prepared = state.prepare_chat(&headers, payload()).unwrap();
2617        assert!(matches!(
2618            state.authorize_session(&headers, &prepared.session, "session:close"),
2619            Err(PipelineError::Unauthorized(_))
2620        ));
2621        headers.insert(
2622            axum::http::header::AUTHORIZATION,
2623            HeaderValue::from_str(&format!(
2624                "Bearer {}",
2625                scoped_token(&["chat:write", "session:close"])
2626            ))
2627            .unwrap(),
2628        );
2629        state
2630            .authorize_session(&headers, &prepared.session, "session:close")
2631            .unwrap();
2632    }
2633
2634    #[tokio::test]
2635    async fn upstream_failure_is_added_to_signed_chain() {
2636        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
2637        let state = state(config);
2638        let mut headers = HeaderMap::new();
2639        headers.insert(SESSION_HEADER, HeaderValue::from_static("upstream-failure"));
2640        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
2641        let prepared = state.prepare_chat(&headers, payload()).unwrap();
2642        let session = Arc::clone(&prepared.session);
2643        assert!(matches!(
2644            state.forward_chat(prepared).await,
2645            Err(PipelineError::Upstream(_))
2646        ));
2647        tokio::time::timeout(std::time::Duration::from_secs(1), async {
2648            while session.chain.lock().count() < 2 {
2649                tokio::task::yield_now().await;
2650            }
2651        })
2652        .await
2653        .unwrap();
2654    }
2655
2656    /// Regression lock for CWE-209 information exposure. Before the fix,
2657    /// `PipelineError::Upstream(reqwest_err.to_string())` embedded the
2658    /// operator-configured upstream URL in the JSON error body — any client
2659    /// that triggered an upstream failure could discover the URL, potentially
2660    /// leaking an internal hostname. The client-facing message must now be a
2661    /// stable category ("upstream unreachable", "upstream timed out", …) that
2662    /// does not depend on the URL.
2663    #[tokio::test]
2664    async fn upstream_failure_message_does_not_leak_configured_url() {
2665        // Use a distinctive private-network host so a regression is unmissable
2666        // in the assertion below.
2667        let sentinel_url = "http://internal-sentinel-host.corp.example:65001";
2668        let config = HarnessConfig::for_tests(sentinel_url, "/tmp", "/tmp");
2669        let state = state(config);
2670        let mut headers = HeaderMap::new();
2671        headers.insert(SESSION_HEADER, HeaderValue::from_static("cwe-209-check"));
2672        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
2673        let prepared = state.prepare_chat(&headers, payload()).unwrap();
2674        let error = match state.forward_chat(prepared).await {
2675            Ok(_) => panic!("connect to unroutable host must fail"),
2676            Err(error) => error,
2677        };
2678        let message = error.to_string();
2679        assert!(
2680            !message.contains("internal-sentinel-host"),
2681            "upstream error message {message:?} leaks the configured URL"
2682        );
2683        assert!(
2684            !message.contains("65001"),
2685            "upstream error message {message:?} leaks the configured port"
2686        );
2687        assert!(
2688            !message.contains("corp.example"),
2689            "upstream error message {message:?} leaks the configured domain"
2690        );
2691    }
2692
2693    /// Regression lock for CWE-209 information exposure. Before the fix,
2694    /// `PipelineError::Unauthorized(identity_err.to_string())` embedded the
2695    /// specific `IdentityError` variant's `Display` text in the JSON error
2696    /// body. Because that text distinguishes at least sixteen distinct
2697    /// failure modes — and echoes the attacker-supplied `kid`, `iss`, and
2698    /// `alg` values — each response became an enumeration oracle: an
2699    /// attacker could iterate candidate `kid`s and read the response body
2700    /// to discover which are configured on our validator. The client-facing
2701    /// message must now be a single stable string, and must never echo
2702    /// attacker-controlled token fields.
2703    #[test]
2704    fn identity_validation_failure_does_not_leak_kid_or_issuer() {
2705        // Every variant that an attacker can reach via a crafted token must
2706        // classify to exactly the same client-facing string. Any divergence
2707        // is an enumeration oracle for validator configuration.
2708        //
2709        // Sentinels that would appear in the raw `Display` output are
2710        // asserted absent — this catches accidental future variants that
2711        // slip through the `_` catch-all with a different classification.
2712        let sentinel_kid = "sentinel-attacker-kid";
2713        let sentinel_iss = "https://sentinel-attacker-iss.example.invalid";
2714        let sentinel_alg = "HS512";
2715        let cases: Vec<(&'static str, av_identity::IdentityError)> = vec![
2716            (
2717                "Malformed",
2718                av_identity::IdentityError::Malformed("jwt parse".into()),
2719            ),
2720            ("MissingKid", av_identity::IdentityError::MissingKid),
2721            (
2722                "UnknownKid",
2723                av_identity::IdentityError::UnknownKid(sentinel_kid.into()),
2724            ),
2725            (
2726                "AlgorithmRejected",
2727                av_identity::IdentityError::AlgorithmRejected {
2728                    alg: sentinel_alg.into(),
2729                    kid: sentinel_kid.into(),
2730                },
2731            ),
2732            (
2733                "Verification",
2734                av_identity::IdentityError::Verification("bad sig".into()),
2735            ),
2736            ("TtlTooLong", av_identity::IdentityError::TtlTooLong(9999)),
2737            (
2738                "BadTimestamps",
2739                av_identity::IdentityError::BadTimestamps { iat: 10, exp: 5 },
2740            ),
2741            (
2742                "FutureIat",
2743                av_identity::IdentityError::FutureIat {
2744                    iat: 999_999_999,
2745                    now: 1,
2746                },
2747            ),
2748            ("EmptyField", av_identity::IdentityError::EmptyField("charter")),
2749            (
2750                "SpoofingCharacter",
2751                av_identity::IdentityError::SpoofingCharacter("charter"),
2752            ),
2753            (
2754                "BadIssuer",
2755                av_identity::IdentityError::BadIssuer(sentinel_iss.into()),
2756            ),
2757            (
2758                "ScopeEscalation",
2759                av_identity::IdentityError::ScopeEscalation {
2760                    scope: "chat:write".into(),
2761                },
2762            ),
2763            (
2764                "ExpEscalation",
2765                av_identity::IdentityError::ExpEscalation {
2766                    child: 100,
2767                    parent: 50,
2768                },
2769            ),
2770            ("ChainTooDeep", av_identity::IdentityError::ChainTooDeep(5)),
2771        ];
2772
2773        let mut classifications: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
2774        for (name, error) in &cases {
2775            let client_msg = super::classify_identity_error(error);
2776            classifications.insert(client_msg);
2777            assert!(
2778                !client_msg.contains(sentinel_kid),
2779                "{name}: client message {client_msg:?} echoes attacker kid"
2780            );
2781            assert!(
2782                !client_msg.contains(sentinel_iss),
2783                "{name}: client message {client_msg:?} echoes attacker iss"
2784            );
2785            assert!(
2786                !client_msg.contains(sentinel_alg),
2787                "{name}: client message {client_msg:?} echoes attacker alg"
2788            );
2789            // The full PipelineError as rendered to the client must also
2790            // not leak — this is what actually goes on the wire via
2791            // `pipeline_error()` -> `Json(json!({"error": err.to_string()}))`.
2792            let wire = PipelineError::Unauthorized(client_msg.to_owned()).to_string();
2793            assert!(
2794                !wire.contains(sentinel_kid),
2795                "{name}: wire error {wire:?} echoes attacker kid"
2796            );
2797            assert!(
2798                !wire.contains(sentinel_iss),
2799                "{name}: wire error {wire:?} echoes attacker iss"
2800            );
2801            assert!(
2802                !wire.contains(sentinel_alg),
2803                "{name}: wire error {wire:?} echoes attacker alg"
2804            );
2805        }
2806        // All attacker-reachable variants collapse to exactly one class —
2807        // the `Jwks` variant (server-side misconfig only, not on the
2808        // request path) may distinctly classify.
2809        assert_eq!(
2810            classifications.len(),
2811            1,
2812            "attacker-reachable IdentityError variants classify to multiple messages {classifications:?}: this is an enumeration oracle"
2813        );
2814
2815        // And Jwks classifies distinctly (server-side, not attacker-reachable).
2816        let jwks_msg = super::classify_identity_error(&av_identity::IdentityError::Jwks("bad jwks".into()));
2817        assert_ne!(
2818            jwks_msg,
2819            *classifications.iter().next().unwrap(),
2820            "server-side misconfig should classify distinctly for operator dashboards"
2821        );
2822    }
2823
2824    /// The secret reader must fail loudly on missing/empty sources and
2825    /// trim whitespace — silent unauthenticated proxying is the worst
2826    /// failure mode because the operator sees only upstream 401s.
2827    #[test]
2828    fn read_secret_source_handling() {
2829        let env = |name: &str| -> Option<String> {
2830            match name {
2831                "GOOD_KEY" => Some("sk-live-123\n".into()),
2832                "EMPTY_KEY" => Some("   ".into()),
2833                _ => None,
2834            }
2835        };
2836        assert_eq!(
2837            super::read_secret_from(env, Some("GOOD_KEY"), None, "upstream API key")
2838                .unwrap()
2839                .as_deref(),
2840            Some("sk-live-123"),
2841            "value must be trimmed"
2842        );
2843        assert!(super::read_secret_from(env, Some("MISSING_KEY"), None, "upstream API key").is_err());
2844        assert!(super::read_secret_from(env, Some("EMPTY_KEY"), None, "upstream API key").is_err());
2845        assert!(super::read_secret_from(env, None, None, "upstream API key")
2846            .unwrap()
2847            .is_none());
2848
2849        // File source: owner-only accepted (with trim), world-readable refused.
2850        let dir = tempfile::tempdir().unwrap();
2851        let path = dir.path().join("key");
2852        std::fs::write(&path, "sk-file-456\n").unwrap();
2853        #[cfg(unix)]
2854        {
2855            use std::os::unix::fs::PermissionsExt as _;
2856            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2857            let error = super::read_secret_from(env, None, path.to_str(), "upstream API key").unwrap_err();
2858            assert!(error.to_string().contains("must be owner-only"), "{error}");
2859            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2860        }
2861        assert_eq!(
2862            super::read_secret_from(env, None, path.to_str(), "upstream API key")
2863                .unwrap()
2864                .as_deref(),
2865            Some("sk-file-456")
2866        );
2867        // Missing file is a hard error, not silent None.
2868        assert!(
2869            super::read_secret_from(env, None, dir.path().join("absent").to_str(), "upstream API key")
2870                .is_err()
2871        );
2872    }
2873
2874    /// Auth resolution renders scheme-prefixed and raw header values,
2875    /// marks them sensitive, and never leaks the key through `describe`.
2876    #[test]
2877    fn upstream_auth_resolution_and_description() {
2878        let dir = tempfile::tempdir().unwrap();
2879        let key_path = dir.path().join("azure.key");
2880        std::fs::write(&key_path, "azure-raw-key").unwrap();
2881        #[cfg(unix)]
2882        {
2883            use std::os::unix::fs::PermissionsExt as _;
2884            std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2885        }
2886
2887        // Bearer scheme.
2888        let mut config = HarnessConfig::for_tests("http://up", "spool", "bridge");
2889        config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
2890        let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
2891        assert_eq!(name.as_str(), "authorization");
2892        assert_eq!(value.to_str().unwrap(), "Bearer azure-raw-key");
2893        assert!(value.is_sensitive(), "credential must redact in Debug output");
2894
2895        // Raw scheme (Azure api-key style).
2896        config.upstream_auth_header = "api-key".into();
2897        config.upstream_auth_scheme = String::new();
2898        let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
2899        assert_eq!(name.as_str(), "api-key");
2900        assert_eq!(value.to_str().unwrap(), "azure-raw-key");
2901
2902        // Description names the source but never the value.
2903        let described = super::describe_upstream_auth(&config);
2904        assert!(described.contains("api-key from file"), "{described}");
2905        assert!(!described.contains("azure-raw-key"), "{described}");
2906
2907        // No auth configured resolves to None and describes as none.
2908        let bare = HarnessConfig::for_tests("http://up", "spool", "bridge");
2909        assert!(super::resolve_upstream_auth(&bare).unwrap().is_none());
2910        assert_eq!(super::describe_upstream_auth(&bare), "none");
2911
2912        // Tool bearer renders Bearer form from a file source.
2913        let mut tooling = HarnessConfig::for_tests("http://up", "spool", "bridge");
2914        tooling.tool_upstream_url = Some("http://tools/mcp".into());
2915        tooling.tool_upstream_bearer_file = Some(key_path.to_string_lossy().into_owned());
2916        let bearer = super::resolve_tool_auth(&tooling).unwrap().unwrap();
2917        assert_eq!(bearer.to_str().unwrap(), "Bearer azure-raw-key");
2918        assert!(bearer.is_sensitive());
2919    }
2920}