1use 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
25pub const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
27
28pub(crate) const TOOL_INVOKE_SCOPE: &str = "tool:invoke";
30
31pub(crate) fn tool_scope(tool: &str) -> String {
33 format!("tool:{tool}")
34}
35
36#[derive(Clone)]
38pub struct AppState {
39 pub config: Arc<HarnessConfig>,
41 pub store: Arc<dyn StateStore>,
43 pub sandbox: Arc<Sandbox>,
45 pub bridge: Arc<dyn EventBus>,
48 pub sessions: Arc<SessionRegistry>,
50 pub worker: WorkerHandle,
52 pub identity: Option<Arc<IdentityValidator>>,
54 pub metrics: Arc<Registry>,
56 pub client: reqwest::Client,
58 pub(crate) upstream_auth: Option<(HeaderName, HeaderValue)>,
61 pub(crate) tool_auth: Option<HeaderValue>,
63 pub finalizer: Finalizer,
65 pub(crate) journal_key: [u8; 32],
66}
67
68pub 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
85pub(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
121pub(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
140fn 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
151fn 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
186fn 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
221pub struct PreparedRequest {
223 pub session: Arc<Session>,
225 pub identity: AgentIdentity,
227 pub payload: Value,
229 pub middleware_us: u64,
231 lease: SessionLease,
232 response_permit: Option<ResponsePermit>,
233 response_attempt_id: String,
234 client_authorization: Option<HeaderValue>,
236}
237
238pub struct ForwardedResponse {
240 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#[derive(Debug, thiserror::Error)]
250#[non_exhaustive]
251pub enum PipelineError {
252 #[error("bad request: {0}")]
254 BadRequest(String),
255 #[error("unauthorized: {0}")]
257 Unauthorized(String),
258 #[error("request blocked: {0}")]
260 Blocked(String),
261 #[error("upstream request failed: {0}")]
263 Upstream(String),
264 #[error("connection aborted: {0}")]
266 Abort(String),
267 #[error("audit capture unavailable: {0}")]
269 Unavailable(String),
270}
271
272impl PipelineError {
273 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 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 #[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 #[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 #[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 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 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 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 .user_agent(concat!("AgentVisor AI/", env!("CARGO_PKG_VERSION")))
503 .tcp_keepalive(std::time::Duration::from_secs(30))
509 .pool_idle_timeout(std::time::Duration::from_secs(60));
514 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 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 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 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 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 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 let _ = permit.submit(&self.worker, job);
843 }
844
845 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 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 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 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 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 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 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 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 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 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 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 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 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 (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
1416pub(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
1437fn classify_identity_error(error: &av_identity::IdentityError) -> &'static str {
1454 match error {
1455 av_identity::IdentityError::Jwks(_) => "identity validator misconfigured",
1460 _ => "identity validation failed",
1464 }
1465}
1466
1467pub(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
1492pub(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 return None;
1514 }
1515 if rest.is_empty() {
1516 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
1561fn 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 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 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 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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 let msg = bad_request_message(serde_json::json!({
2401 "input": [{ "role": "user", "content": "hi" }]
2402 }));
2403 assert!(msg.contains("missing 'messages'"), "got {msg}");
2404 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 let msg = bad_request_message(serde_json::json!({ "messages": [] }));
2412 assert!(msg.contains("empty"), "got {msg}");
2413 }
2414
2415 #[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 #[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 #[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 #[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 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 #[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 assert_eq!(strip_bearer_scheme("Bearer\taaaa"), Some("aaaa"));
2525 assert_eq!(strip_bearer_scheme("Bearer aaaa"), Some("aaaa"));
2527 assert_eq!(strip_bearer_scheme("BearerXYZ"), None);
2529 assert_eq!(strip_bearer_scheme("Bearer "), None);
2530 assert_eq!(strip_bearer_scheme("Bearer"), None);
2531 assert_eq!(strip_bearer_scheme("Basic ZmY6bGFyZQ=="), None);
2533 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 #[tokio::test]
2664 async fn upstream_failure_message_does_not_leak_configured_url() {
2665 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 #[test]
2704 fn identity_validation_failure_does_not_leak_kid_or_issuer() {
2705 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 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 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 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 #[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 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 assert!(
2869 super::read_secret_from(env, None, dir.path().join("absent").to_str(), "upstream API key")
2870 .is_err()
2871 );
2872 }
2873
2874 #[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 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 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 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 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 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}