diff --git a/CHANGELOG.md b/CHANGELOG.md index 93891a271..36c2e8dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011). - `tepp_api` adaptive orchestration router (ADR 0010): versioned `direct`/`verify`/`committee`/`conductor`/`abstain` selection from CPU `f64` risk, ambiguity, evidence, and token-budget inputs; recorded stages, recursion, decomposition, access lists, and role-specific reasoning effort; fail-closed document-controlled policy/access/credentials; LLM plans remain proposals under deterministic statistical authority; comparable-budget ablation requires a direct baseline; credential-free contextual-orchestrator binding. Live NIM HTTP remains accepted-target. - `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid/inverted/cross-tenant/impossible-calendar denial, semantic UTC calendar validation, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), a separately authorized scientific re-identification path, and an internally bound FIPS 180-4 SHA-256 audit digest appended through `ReidentificationAuditSink` before disclosure. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). diff --git a/Cargo.lock b/Cargo.lock index 616bfd78e..fb502b9cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1270,6 +1270,7 @@ dependencies = [ name = "tepp_api" version = "0.1.0" dependencies = [ + "jiff", "serde", "serde_json", "sha2", diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 027f975ee..b7d27cc7d 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tepp_api" -description = "Versioned service DTOs, schemas, and export contracts." +description = "Versioned service DTOs, schemas, export contracts, and loopback naruon HTTP." version.workspace = true edition.workspace = true rust-version.workspace = true @@ -14,6 +14,7 @@ categories.workspace = true publish = false [dependencies] +jiff = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index 16ac6ba80..b263a1a34 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -4,7 +4,9 @@ use crate::ApiError; use crate::wire::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, }; +use jiff::Timestamp; use serde::{Deserialize, Serialize}; +use temporal_core::KnowledgeCutoff; /// Supported analysis-run contract version. pub const ANALYSIS_RUN_CONTRACT_VERSION: u16 = 1; @@ -83,13 +85,29 @@ impl AnalysisRunRequest { require_nonempty(&self.idempotency_key)?; require_nonempty(&self.tenant_workspace_id)?; require_nonempty(&self.snapshot_id)?; - require_nonempty(&self.knowledge_cutoff)?; + require_rfc3339_knowledge_cutoff(&self.knowledge_cutoff)?; require_nonempty(&self.model_contract_version)?; require_nonempty(&self.output_profile)?; Ok(()) } } +/// Parse `knowledge_cutoff` as a TEPP clock and refuse a cutoff after now. +/// +/// A buyer cannot claim analysis of evidence that is not yet available. The +/// request receipt instant is treated as availability of the command itself. +fn require_rfc3339_knowledge_cutoff(knowledge_cutoff: &str) -> Result<(), ApiError> { + require_nonempty(knowledge_cutoff)?; + let cutoff = KnowledgeCutoff::parse_rfc3339(knowledge_cutoff) + .map_err(|_| ApiError::InvalidWirePayload)?; + let receipt = KnowledgeCutoff::parse_rfc3339(&Timestamp::now().to_string()) + .map_err(|_| ApiError::InvalidWirePayload)?; + if cutoff.instant() > receipt.instant() { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + impl AnalysisRunAccepted { /// Construct a validated accepted-run response. /// @@ -223,6 +241,32 @@ mod tests { bad.output_profile.clear(); assert_eq!(bad.to_json(), Err(ApiError::InvalidWirePayload)); + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a\u001fb","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a","tenant_workspace_id":"t\u001fb","snapshot_id":"s","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"k","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRequest::from_json( + r#"{"contract_version":1,"idempotency_key":"a","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"2099-01-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + let accepted = AnalysisRunAccepted::new("run-1", "accepted", "idem-1").expect("acc"); let accepted_json = accepted.to_json().expect("aj"); assert_eq!( diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 411da83f2..1c6fdf0f3 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -6,8 +6,9 @@ //! component without sharing application tables. Domain estimation remains in //! scientific crates; this crate only defines fail-closed interchange shapes. //! naruon HTTP interchange is a versioned `https` POST to analysis-run and -//! export paths; table-access URLs, review/Copilot headers, and lexical -//! inference claims fail closed (ADR 0011). +//! export paths; table-access URLs, review/Copilot/NIM/proxy headers, and +//! lexical inference claims fail closed. A loopback live listener proves +//! those POSTs over TCP without claiming production TLS (ADR 0011). mod analysis_run; mod authorization; @@ -15,6 +16,7 @@ mod envelope; mod error; mod export; mod naruon_http; +mod naruon_live; mod orchestration; mod provider_payload; mod wire; @@ -68,6 +70,16 @@ pub use naruon_http::naruon_analysis_run_exchange_with_headers; pub use naruon_http::naruon_export_exchange; /// Refuse lexical heuristics as TEPP inference claims. pub use naruon_http::naruon_may_claim_tepp_inference; +/// Maximum live HTTP header-block bytes. +pub use naruon_live::NARUON_LIVE_HEADER_BYTE_LIMIT; +/// Maximum live HTTP header count. +pub use naruon_live::NARUON_LIVE_HEADER_COUNT_LIMIT; +/// Accepted-stream read/write deadline. +pub use naruon_live::NARUON_LIVE_IO_TIMEOUT; +/// HTTP/1.1 response from the naruon live listener. +pub use naruon_live::NaruonLiveResponse; +/// Loopback live HTTP/1.1 service for naruon POSTs. +pub use naruon_live::NaruonLiveService; /// Comparable-budget ablation record. pub use orchestration::BudgetAblationRecord; /// Credential-free contextual-orchestrator binding. diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 2d2d0083c..2d1a6c2fc 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -128,9 +128,7 @@ fn compose_https_target(origin: &str, path: &str) -> Result { || host.contains('/') || host.contains('?') || host.contains('#') - || host - .chars() - .any(|ch| ch.is_control() || matches!(ch, '\'' | ';' | '\\' | ' ')) + || host.chars().any(|ch| matches!(ch, '\'' | ';' | '\\' | ' ')) { return Err(ApiError::InvalidWirePayload); } @@ -142,22 +140,34 @@ fn compose_https_target(origin: &str, path: &str) -> Result { Ok(format!("{origin}{path}")) } +/// Return whether `name` is a reserved naruon interchange header. +pub(crate) fn header_is_reserved_standard(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "content-type" | "tepp-consumer" | "tepp-contract-version" | "idempotency-key" + ) +} + +/// Return whether `name` is a review, model, proxy, or bearer credential header. +pub(crate) fn header_is_credential(name: &str) -> bool { + let lowered = name.to_ascii_lowercase(); + lowered == "authorization" + || lowered == "proxy-authorization" + || lowered == "cookie" + || lowered == "x-api-key" + || lowered.contains("token") + || lowered.contains("copilot") + || lowered.contains("github") + || lowered.contains("nim") + || lowered.contains("nvidia") +} + fn refuse_credential_headers(extra_headers: &[(&str, &str)]) -> Result<(), ApiError> { for (name, _) in extra_headers { - let lowered = name.to_ascii_lowercase(); - if matches!( - lowered.as_str(), - "content-type" | "tepp-consumer" | "tepp-contract-version" | "idempotency-key" - ) { + if header_is_reserved_standard(name) { return Err(ApiError::InvalidWirePayload); } - if lowered == "authorization" - || lowered == "cookie" - || lowered == "x-api-key" - || lowered.contains("token") - || lowered.contains("copilot") - || lowered.contains("github") - { + if header_is_credential(name) { return Err(ApiError::AuthorizationDenied); } } @@ -176,10 +186,10 @@ fn standard_headers(idempotency_key: &str) -> Vec<(String, String)> { #[cfg(test)] mod tests { use super::{ - NARUON_TEPP_INFERENCE_METHOD, compose_https_target, naruon_may_claim_tepp_inference, - refuse_credential_headers, + NARUON_TEPP_INFERENCE_METHOD, compose_https_target, naruon_export_exchange, + naruon_may_claim_tepp_inference, refuse_credential_headers, }; - use crate::ApiError; + use crate::{AnalyticalPurpose, ApiError, ExportAuthorizationRequest}; #[test] fn compose_https_target_accepts_clean_origin_and_rejects_hostile_forms() { @@ -298,6 +308,37 @@ mod tests { refuse_credential_headers(&[("x-copilot-session", "t")]), Err(ApiError::AuthorizationDenied) ); + assert_eq!( + refuse_credential_headers(&[("Proxy-Authorization", "Basic x")]), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + refuse_credential_headers(&[("x-nvidia-nim-key", "nvapi-x")]), + Err(ApiError::AuthorizationDenied) + ); + } + + #[test] + fn naruon_export_exchange_covers_unit_test_purpose_gate() { + let allowed = ExportAuthorizationRequest { + tenant_workspace_id: "tenant-a".into(), + principal_id: "naruon-service".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-a".into(), + includes_source_text: false, + }; + assert!( + naruon_export_exchange("https://tepp.example.test", &allowed, "export-idem-a").is_ok() + ); + + let denied = ExportAuthorizationRequest { + purpose: AnalyticalPurpose::OperationalMonitoring, + ..allowed + }; + assert_eq!( + naruon_export_exchange("https://tepp.example.test", &denied, "export-idem-b"), + Err(ApiError::AuthorizationDenied) + ); } #[test] diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs new file mode 100644 index 000000000..b068fedd1 --- /dev/null +++ b/crates/tepp_api/src/naruon_live.rs @@ -0,0 +1,708 @@ +//! Loopback-only live HTTP/1.1 listener for naruon modular POSTs (ADR 0011). + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::time::Duration; + +use crate::authorization::{ + AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, +}; +use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH, header_is_credential}; +use crate::wire::{from_json, to_json}; +use crate::{ + AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, + ErrorEnvelope, requests_are_idempotent_matches, +}; + +/// Maximum request-line plus header bytes accepted before the body. +pub const NARUON_LIVE_HEADER_BYTE_LIMIT: usize = 8 * 1024; + +/// Maximum number of HTTP header lines on one live request. +pub const NARUON_LIVE_HEADER_COUNT_LIMIT: usize = 32; + +/// Read and write deadline installed on every accepted stream. +pub const NARUON_LIVE_IO_TIMEOUT: Duration = Duration::from_secs(1); + +/// HTTP/1.1 response produced by the naruon live listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NaruonLiveResponse { + /// Numeric status code. + pub status_code: u16, + /// RFC 9110 reason phrase paired with [`Self::status_code`]. + pub reason_phrase: &'static str, + /// JSON accepted-run, export-decision, or redacted error envelope. + pub body: String, +} + +/// Loopback live HTTP/1.1 service for naruon analysis-run and export POSTs. +/// +/// Production interchange origins remain `https` only. This listener binds +/// loopback TCP so tests and local standalone operation can prove request +/// handling without claiming TLS termination or cross-service table access. +/// This port only accepts versioned naruon POSTs. +#[derive(Debug)] +pub struct NaruonLiveService { + listener: Option, + bound_addr: Option, + next_run_serial: u64, + next_request_serial: u64, + accepted_runs: HashMap, +} + +impl Default for NaruonLiveService { + fn default() -> Self { + Self::new() + } +} + +impl NaruonLiveService { + /// Construct an in-memory handler with no socket. + #[must_use] + pub fn new() -> Self { + Self { + listener: None, + bound_addr: None, + next_run_serial: 1, + next_request_serial: 1, + accepted_runs: HashMap::new(), + } + } + + /// Bind `127.0.0.1:0`. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the operating system + /// refuses the loopback bind. + pub fn bind_loopback() -> Result { + Self::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + } + + /// Bind a caller-supplied address after refusing non-loopback IPs. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback bind + /// address and [`ApiError::InvalidWirePayload`] when the socket cannot + /// be opened. + pub fn bind(addr: SocketAddr) -> Result { + if !addr.ip().is_loopback() { + return Err(ApiError::AuthorizationDenied); + } + let listener = TcpListener::bind(addr).map_err(|error| map_io_error(&error))?; + let bound_addr = listener + .local_addr() + .map_err(|error| map_io_error(&error))?; + let mut service = Self::new(); + service.listener = Some(listener); + service.bound_addr = Some(bound_addr); + Ok(service) + } + + /// Return the bound loopback address. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when no socket is bound. + pub fn local_addr(&self) -> Result { + self.bound_addr.ok_or(ApiError::InvalidWirePayload) + } + + /// Accept one TCP connection and serve one HTTP/1.1 request. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when no socket is bound or + /// the accept/write path fails for a non-timeout reason. Timeouts map to + /// [`ApiError::LimitExceeded`]. + pub fn serve_one(&mut self) -> Result { + let listener = self.listener.as_ref().ok_or(ApiError::InvalidWirePayload)?; + self.serve_accepted(listener.accept().map(|(stream, _)| stream)) + } + + /// Serve one already-accepted stream, or map an accept failure. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] + /// when accept or response writing fails. Request-protocol failures become + /// HTTP error responses and are returned as `Ok`. + pub fn serve_accepted( + &mut self, + accepted: Result, + ) -> Result { + let mut stream = accepted.map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + let response = match Self::read_http_request(&mut stream) { + Ok(request) => self.handle_http_request(&request), + Err(error) => self.response_from_error(error), + }; + Self::write_response(&mut stream, &response)?; + Ok(response) + } + + /// Parse and handle a complete HTTP/1.1 request already in memory. + #[must_use] + pub fn handle_http_request(&mut self, request: &str) -> NaruonLiveResponse { + match self.dispatch_http_request(request) { + Ok(response) => response, + Err(error) => self.response_from_error(error), + } + } + + /// Read one HTTP/1.1 request from `reader`, including the declared body. + /// + /// # Errors + /// + /// Returns [`ApiError::LimitExceeded`] on timeout or when headers exceed + /// [`NARUON_LIVE_HEADER_BYTE_LIMIT`]. Other read/framing failures are + /// [`ApiError::InvalidWirePayload`]. + pub fn read_http_request(reader: &mut R) -> Result { + let mut header_bytes = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + if header_bytes.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let read = reader + .read(&mut byte) + .map_err(|error| map_io_error(&error))?; + if read == 0 { + return Err(ApiError::InvalidWirePayload); + } + header_bytes.push(byte[0]); + if header_bytes.ends_with(b"\r\n\r\n") { + break; + } + } + let header_text = + std::str::from_utf8(&header_bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let content_length = declared_content_length(header_text)?; + if content_length > DEFAULT_ANALYSIS_RUN_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let mut body = vec![0_u8; content_length]; + if content_length > 0 { + reader + .read_exact(&mut body) + .map_err(|error| map_io_error(&error))?; + } + let body_text = std::str::from_utf8(&body).map_err(|_| ApiError::InvalidWirePayload)?; + Ok(format!("{header_text}{body_text}")) + } + + /// Write one HTTP/1.1 response to `writer`. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the write fails. + pub fn write_response( + writer: &mut W, + response: &NaruonLiveResponse, + ) -> Result<(), ApiError> { + writer + .write_all(&response.to_http_bytes()) + .map_err(|error| map_io_error(&error))?; + writer.flush().map_err(|error| map_io_error(&error)) + } + + fn dispatch_http_request(&mut self, request: &str) -> Result { + let (header_block, body) = split_request(request)?; + let mut lines = header_block.split("\r\n"); + let request_line = lines.next().unwrap_or(""); + let (method, path) = parse_request_line(request_line)?; + if method != "POST" { + return Err(ApiError::InvalidWirePayload); + } + if path != NARUON_ANALYSIS_RUN_PATH && path != NARUON_EXPORT_PATH { + return Err(ApiError::InvalidWirePayload); + } + let headers = parse_headers(lines)?; + refuse_live_headers(&headers, self.bound_addr)?; + self.dispatch_path(path, &headers, body) + } + + fn dispatch_path( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if path == NARUON_ANALYSIS_RUN_PATH { + self.accept_analysis_run(headers, body) + } else { + Self::authorize_export(headers, body) + } + } + + fn accept_analysis_run( + &mut self, + headers: &HashMap, + body: &str, + ) -> Result { + let request = AnalysisRunRequest::from_json(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key != request.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = tenant_idempotency_key(&request.tenant_workspace_id, idempotency_key); + if let Some((stored_request, stored_accepted)) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(stored_request, &request) { + return Ok(NaruonLiveResponse::json( + 202, + "Accepted", + stored_accepted.to_json()?, + )); + } + return Err(ApiError::InvalidWirePayload); + } + let run_id = format!("naruon-run-{}", self.next_run_serial); + self.next_run_serial += 1; + let accepted = + AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; + let body = accepted.to_json()?; + self.accepted_runs.insert(replay_key, (request, accepted)); + Ok(NaruonLiveResponse::json(202, "Accepted", body)) + } + + fn authorize_export( + headers: &HashMap, + body: &str, + ) -> Result { + let request: ExportAuthorizationRequest = from_json(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key == request.principal_id { + return Err(ApiError::InvalidWirePayload); + } + if request.purpose != AnalyticalPurpose::ModularServiceConsumer { + return Err(ApiError::AuthorizationDenied); + } + let decision = authorize_export(&request)?; + require_export_allowed(&decision)?; + Ok(NaruonLiveResponse::json(200, "OK", to_json(&decision)?)) + } + + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { + let request_id = format!("naruon-live-{}", self.next_request_serial); + self.next_request_serial += 1; + let (status_code, reason_phrase) = status_for(error); + NaruonLiveResponse::json(status_code, reason_phrase, envelope_json(error, request_id)) + } +} + +impl NaruonLiveResponse { + fn json(status_code: u16, reason_phrase: &'static str, body: String) -> Self { + Self { + status_code, + reason_phrase, + body, + } + } + + /// Render the response as an HTTP/1.1 message. + #[must_use] + pub fn to_http_bytes(&self) -> Vec { + format!( + "HTTP/1.1 {} {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + self.status_code, + self.reason_phrase, + self.body.len(), + self.body + ) + .into_bytes() + } +} + +fn tenant_idempotency_key(tenant_workspace_id: &str, idempotency_key: &str) -> String { + format!("{tenant_workspace_id}\u{1f}{idempotency_key}") +} + +fn envelope_json(error: ApiError, request_id: String) -> String { + ErrorEnvelope::from_api_error(error, request_id) + .and_then(|envelope| envelope.to_json()) + .unwrap_or_else(|_| fallback_envelope_json()) +} + +fn fallback_envelope_json() -> String { + "{\"error_code\":\"invalid_wire_payload\",\"message\":\"invalid API wire payload\",\"request_id\":\"naruon-live-fallback\",\"retryable\":false}".to_owned() +} + +fn status_for(error: ApiError) -> (u16, &'static str) { + match error { + ApiError::InvalidWirePayload => (400, "Bad Request"), + ApiError::AuthorizationDenied => (403, "Forbidden"), + ApiError::LimitExceeded => (413, "Payload Too Large"), + ApiError::UnsupportedContractVersion => (422, "Unprocessable Entity"), + } +} + +fn map_io_error(error: &std::io::Error) -> ApiError { + match error.kind() { + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ApiError::LimitExceeded, + _ => ApiError::InvalidWirePayload, + } +} + +fn split_request(request: &str) -> Result<(&str, &str), ApiError> { + let Some(index) = request.find("\r\n\r\n") else { + if request.len() >= NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + return Err(ApiError::InvalidWirePayload); + }; + if index > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let header_block = &request[..index]; + let body = &request[index + 4..]; + let declared = declared_content_length(&format!("{header_block}\r\n\r\n"))?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + if declared > DEFAULT_ANALYSIS_RUN_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + Ok((header_block, body)) +} + +fn declared_content_length(header_text: &str) -> Result { + let header_block = header_text + .strip_suffix("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut found = None; + for line in header_block.split("\r\n").skip(1) { + let (name, value) = split_header_line(line)?; + if name.eq_ignore_ascii_case("content-length") { + if found.is_some() { + return Err(ApiError::InvalidWirePayload); + } + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ApiError::InvalidWirePayload); + } + found = Some(value.parse().map_err(|_| ApiError::InvalidWirePayload)?); + } + } + found.ok_or(ApiError::InvalidWirePayload) +} + +fn parse_request_line(line: &str) -> Result<(&str, &str), ApiError> { + let mut parts = line.split(' '); + let method = parts.next().ok_or(ApiError::InvalidWirePayload)?; + let path = parts.next().ok_or(ApiError::InvalidWirePayload)?; + let version = parts.next().ok_or(ApiError::InvalidWirePayload)?; + if parts.next().is_some() || version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + if !path.starts_with('/') || path.contains('?') || path.contains('#') || path.contains("://") { + return Err(ApiError::InvalidWirePayload); + } + Ok((method, path)) +} + +fn parse_headers<'a, I>(lines: I) -> Result, ApiError> +where + I: Iterator, +{ + let mut headers = HashMap::new(); + let mut count = 0_usize; + for line in lines { + count += 1; + if count > NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = split_header_line(line)?; + let key = name.to_ascii_lowercase(); + if headers.contains_key(&key) { + return Err(ApiError::InvalidWirePayload); + } + headers.insert(key, value.to_owned()); + } + Ok(headers) +} + +fn split_header_line(line: &str) -> Result<(&str, &str), ApiError> { + let Some((name, value)) = line.split_once(':') else { + return Err(ApiError::InvalidWirePayload); + }; + if name.is_empty() || name.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + return Err(ApiError::InvalidWirePayload); + } + Ok((name, value.trim())) +} + +fn refuse_live_headers( + headers: &HashMap, + bound_addr: Option, +) -> Result<(), ApiError> { + for name in headers.keys() { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + } + if headers.contains_key("transfer-encoding") { + return Err(ApiError::InvalidWirePayload); + } + let host = header_value(headers, "host")?; + if host_implies_table_access(host) { + return Err(ApiError::InvalidWirePayload); + } + if !host_is_loopback(host, bound_addr) { + return Err(ApiError::AuthorizationDenied); + } + if header_value(headers, "content-type")? != "application/json" { + return Err(ApiError::InvalidWirePayload); + } + if header_value(headers, "tepp-consumer")? != "naruon" { + return Err(ApiError::InvalidWirePayload); + } + if header_value(headers, "tepp-contract-version")? != "1" { + return Err(ApiError::InvalidWirePayload); + } + let _idempotency_key = header_value(headers, "idempotency-key")?; + Ok(()) +} + +fn header_value<'a>(headers: &'a HashMap, name: &str) -> Result<&'a str, ApiError> { + let value = headers.get(name).ok_or(ApiError::InvalidWirePayload)?; + if value.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + Ok(value.as_str()) +} + +fn host_implies_table_access(host: &str) -> bool { + let lowered = host.to_ascii_lowercase(); + lowered.contains("postgres") + || lowered.contains("jdbc") + || lowered.contains("/sql") + || lowered.contains("/tables/") + || lowered.contains('\'') + || lowered.contains(';') + || lowered.contains('\\') + || lowered.contains(' ') + || lowered.chars().any(char::is_control) +} + +fn host_is_loopback(host: &str, bound_addr: Option) -> bool { + if let Some(bound) = bound_addr + && (host == bound.to_string() || host == bound.ip().to_string()) + { + return true; + } + let lowered = host.to_ascii_lowercase(); + if lowered == "localhost" + || lowered + .strip_prefix("localhost:") + .is_some_and(|port| !port.is_empty() && port.parse::().is_ok()) + { + return true; + } + if let Ok(addr) = host.parse::() { + return addr.ip().is_loopback(); + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + false +} + +#[cfg(test)] +mod tests { + use super::{ + NaruonLiveService, declared_content_length, envelope_json, fallback_envelope_json, + host_implies_table_access, host_is_loopback, map_io_error, parse_request_line, + split_header_line, split_request, status_for, tenant_idempotency_key, + }; + use crate::ApiError; + use std::io::ErrorKind; + use std::net::SocketAddr; + + #[test] + fn helpers_cover_status_io_host_and_request_line_edges() { + assert_eq!( + status_for(ApiError::InvalidWirePayload), + (400, "Bad Request") + ); + assert_eq!( + status_for(ApiError::AuthorizationDenied), + (403, "Forbidden") + ); + assert_eq!( + status_for(ApiError::LimitExceeded), + (413, "Payload Too Large") + ); + assert_eq!( + status_for(ApiError::UnsupportedContractVersion), + (422, "Unprocessable Entity") + ); + assert_eq!( + map_io_error(&std::io::Error::new(ErrorKind::TimedOut, "t")), + ApiError::LimitExceeded + ); + assert_eq!( + map_io_error(&std::io::Error::new(ErrorKind::WouldBlock, "w")), + ApiError::LimitExceeded + ); + assert_eq!( + map_io_error(&std::io::Error::other("x")), + ApiError::InvalidWirePayload + ); + assert!(host_implies_table_access("db.postgres.local")); + assert!(host_implies_table_access("jdbc.local")); + assert!(host_implies_table_access("127.0.0.1/sql")); + assert!(host_implies_table_access("127.0.0.1/tables/x")); + assert!(host_implies_table_access("bad host")); + assert!(host_implies_table_access("bad;host")); + assert!(host_implies_table_access("bad'host")); + assert!(host_implies_table_access("bad\\host")); + assert!(host_implies_table_access("bad\u{0001}host")); + assert!(!host_implies_table_access("127.0.0.1:43789")); + assert!(host_is_loopback("127.0.0.1", None)); + assert!(host_is_loopback("localhost", None)); + assert!(host_is_loopback("localhost:8080", None)); + assert!(!host_is_loopback("localhost:invalid", None)); + assert!(!host_is_loopback("localhost:", None)); + assert!(host_is_loopback("[::1]:9", None)); + assert!(host_is_loopback("::1", None)); + assert!(!host_is_loopback("8.8.8.8", None)); + assert!(!host_is_loopback("attacker.example.com", None)); + let bound: SocketAddr = "127.0.0.1:43789".parse().expect("bound"); + assert!(host_is_loopback("127.0.0.1:43789", Some(bound))); + assert!(host_is_loopback("127.0.0.1", Some(bound))); + assert!(!host_is_loopback("8.8.8.8", Some(bound))); + assert_eq!( + tenant_idempotency_key("tenant-a", "idem-1"), + "tenant-a\u{1f}idem-1" + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn helpers_cover_request_line_headers_and_accept_failure() { + assert_eq!( + parse_request_line("POST /v1/analysis-runs HTTP/1.1 extra"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST https://tepp.example/v1/analysis-runs HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /v1/analysis-runs#x HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /only"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /x HTTP/1.0"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /x?query HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(parse_request_line("POST /x HTTP/1.1"), Ok(("POST", "/x"))); + assert_eq!( + split_header_line("NoColon"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line(": empty-name"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("Bad Name: v"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_header_line("Host: 127.0.0.1").expect("hdr"), + ("Host", "127.0.0.1") + ); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\ncontent-length: 1\r\ncontent-length: 1\r\n\r\n" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\ncontent-length: +1\r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\nHost: 127.0.0.1\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-length: 0\r\n\r\n" + ), + Ok(0) + ); + assert_eq!( + declared_content_length("POST /x HTTP/1.1\r\ncontent-length: \r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_request_line("POST /proxy://target HTTP/1.1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + split_request(&"x".repeat(super::NARUON_LIVE_HEADER_BYTE_LIMIT)), + Err(ApiError::LimitExceeded) + ); + assert_eq!(split_request("short"), Err(ApiError::InvalidWirePayload)); + assert_eq!( + split_request("POST /x HTTP/1.1\r\ncontent-length: 0\r\n\r\n"), + Ok(("POST /x HTTP/1.1\r\ncontent-length: 0", "")) + ); + assert_eq!( + split_request(&format!( + "{}\r\n\r\n", + "x".repeat(super::NARUON_LIVE_HEADER_BYTE_LIMIT + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + split_request("POST /x HTTP/1.1\r\ncontent-length: 1\r\n\r\n"), + Err(ApiError::InvalidWirePayload) + ); + let oversized_body = "x".repeat(super::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1); + assert_eq!( + split_request(&format!( + "POST /x HTTP/1.1\r\ncontent-length: {}\r\n\r\n{oversized_body}", + oversized_body.len() + )), + Err(ApiError::LimitExceeded) + ); + assert!(!fallback_envelope_json().is_empty()); + assert!( + envelope_json(ApiError::InvalidWirePayload, String::new()) + .contains("naruon-live-fallback") + ); + assert!(envelope_json(ApiError::LimitExceeded, "req-1".into()).contains("limit_exceeded")); + assert_eq!( + declared_content_length( + "POST /x HTTP/1.1\r\ncontent-length: 999999999999999999999\r\n\r\n" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + NaruonLiveService::new() + .serve_accepted(Err(std::io::Error::other("accept"))) + .expect_err("accept"), + ApiError::InvalidWirePayload + ); + } +} diff --git a/crates/tepp_api/src/wire.rs b/crates/tepp_api/src/wire.rs index 9ce0ef36f..13b2a3fbe 100644 --- a/crates/tepp_api/src/wire.rs +++ b/crates/tepp_api/src/wire.rs @@ -21,13 +21,14 @@ pub fn from_json<'de, T: Deserialize<'de>>(payload: &'de str) -> Result Result<(), ApiError> { - if value.trim().is_empty() { + if value.trim().is_empty() || value.chars().any(char::is_control) { return Err(ApiError::InvalidWirePayload); } Ok(()) @@ -89,8 +90,13 @@ mod tests { Err(ApiError::InvalidWirePayload) ); require_nonempty("tenant-a").expect("ok"); + require_nonempty("line one two").expect("spaced text stays valid"); assert_eq!(require_nonempty(" "), Err(ApiError::InvalidWirePayload)); assert_eq!(require_nonempty(""), Err(ApiError::InvalidWirePayload)); + assert_eq!( + require_nonempty("topic\u{1f}unit"), + Err(ApiError::InvalidWirePayload) + ); require_byte_limit("abc", 3).expect("ok"); assert_eq!(require_byte_limit("abcd", 3), Err(ApiError::LimitExceeded)); require_contract_version(1, 1).expect("ok"); diff --git a/crates/tepp_api/tests/naruon_http_contract.rs b/crates/tepp_api/tests/naruon_http_contract.rs index 143277126..cb096cb51 100644 --- a/crates/tepp_api/tests/naruon_http_contract.rs +++ b/crates/tepp_api/tests/naruon_http_contract.rs @@ -63,6 +63,9 @@ fn table_access_and_non_https_origins_fail_closed() { "https://tepp.example.test/sql", "https://tepp.example.test/tables/document_record", "http://tepp.example.test", + "https://tepp.example.test/\u{0001}", + "https://tepp.example\u{0001}.test", + "https://tepp.example'.test", "https://tepp.example.test/v1/analysis-runs'; DROP", ] { assert_eq!( @@ -92,6 +95,30 @@ fn review_and_copilot_headers_are_authorization_denied() { ), Err(ApiError::AuthorizationDenied) ); + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[("x-github-actor", "review-agent")] + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[("Proxy-Authorization", "Basic review-agent")] + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[("x-nvidia-nim-key", "nvapi-example")] + ), + Err(ApiError::AuthorizationDenied) + ); } #[test] diff --git a/crates/tepp_api/tests/naruon_live_http_contract.rs b/crates/tepp_api/tests/naruon_live_http_contract.rs new file mode 100644 index 000000000..485f3f506 --- /dev/null +++ b/crates/tepp_api/tests/naruon_live_http_contract.rs @@ -0,0 +1,756 @@ +//! Live loopback HTTP/1.1 naruon POSTs stay versioned and fail closed (ADR 0011). + +use std::fmt::Write as _; +use std::io::{Cursor, Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::thread; +use std::time::{Duration, Instant}; + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunRequest, AnalyticalPurpose, + ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, ExportAuthorizationRequest, + NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, NaruonLiveService, + naruon_analysis_run_exchange, naruon_export_exchange, +}; + +fn sample_run() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-live-idem-001".into(), + tenant_workspace_id: "naruon-tenant-workspace-demo".into(), + snapshot_id: "tepp-snapshot-demo-001".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "topic-measurement-v1".into(), + output_profile: "naruon-consumer-validation-report".into(), + } +} + +fn sample_export() -> ExportAuthorizationRequest { + ExportAuthorizationRequest { + tenant_workspace_id: "naruon-tenant-workspace-demo".into(), + principal_id: "naruon-service".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "tepp-export-demo-001".into(), + includes_source_text: false, + } +} + +fn naruon_headers(idempotency_key: &str) -> Vec<(String, String)> { + vec![ + ("Host".into(), "127.0.0.1".into()), + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ("idempotency-key".into(), idempotency_key.to_owned()), + ] +} + +fn http_request(method: &str, path: &str, headers: &[(String, String)], body: &str) -> String { + let mut request = format!("{method} {path} HTTP/1.1\r\n"); + for (name, value) in headers { + write!(request, "{name}: {value}\r\n").expect("header"); + } + write!(request, "content-length: {}\r\n\r\n{body}", body.len()).expect("len"); + request +} + +fn analysis_http(run: &AnalysisRunRequest) -> String { + http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + &run.to_json().expect("run json"), + ) +} + +fn export_http(request: &ExportAuthorizationRequest, idempotency_key: &str) -> String { + http_request( + "POST", + NARUON_EXPORT_PATH, + &naruon_headers(idempotency_key), + &serde_json::to_string(request).expect("export json"), + ) +} + +fn envelope(body: &str) -> ErrorEnvelope { + serde_json::from_str(body).expect("error envelope") +} + +#[test] +fn loopback_bind_refuses_non_loopback_and_in_use_ports() { + assert_eq!( + NaruonLiveService::bind("0.0.0.0:0".parse::().expect("unspec")) + .expect_err("denied"), + ApiError::AuthorizationDenied + ); + assert_eq!( + NaruonLiveService::bind("8.8.8.8:0".parse::().expect("public")) + .expect_err("denied"), + ApiError::AuthorizationDenied + ); + assert_eq!( + NaruonLiveService::bind("[::]:0".parse::().expect("v6-unspec")) + .expect_err("denied"), + ApiError::AuthorizationDenied + ); + let first = NaruonLiveService::bind_loopback().expect("first bind"); + let addr = first.local_addr().expect("addr"); + assert!(addr.ip().is_loopback()); + assert_eq!( + NaruonLiveService::bind(addr).expect_err("in use"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::new().local_addr().expect_err("no sock"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::new().serve_one().expect_err("no sock"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::default() + .serve_one() + .expect_err("default"), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn handle_http_accepts_analysis_run_and_replays_idempotent_retries() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let first = service.handle_http_request(&analysis_http(&run)); + assert_eq!(first.status_code, 202); + assert_eq!(first.reason_phrase, "Accepted"); + let accepted = AnalysisRunAccepted::from_json(&first.body).expect("accepted"); + assert_eq!(accepted.idempotency_key, run.idempotency_key); + assert_eq!(accepted.run_state, "accepted"); + assert!(!accepted.run_id.is_empty()); + + let replay = service.handle_http_request(&analysis_http(&run)); + assert_eq!(replay.status_code, 202); + assert_eq!(replay.body, first.body); + + let mut conflicting = run.clone(); + conflicting.snapshot_id = "other-snapshot".into(); + let conflict = service.handle_http_request(&analysis_http(&conflicting)); + assert_eq!(conflict.status_code, 400); + assert_eq!( + envelope(&conflict.body).error_code(), + "invalid_wire_payload" + ); +} + +#[test] +fn handle_http_keys_idempotency_replay_by_tenant_and_key() { + let mut service = NaruonLiveService::new(); + let first = sample_run(); + let mut second = first.clone(); + second.tenant_workspace_id = "naruon-tenant-workspace-other".into(); + let a = service.handle_http_request(&analysis_http(&first)); + let b = service.handle_http_request(&analysis_http(&second)); + assert_eq!(a.status_code, 202); + assert_eq!(b.status_code, 202); + let accepted_a = AnalysisRunAccepted::from_json(&a.body).expect("a"); + let accepted_b = AnalysisRunAccepted::from_json(&b.body).expect("b"); + assert_ne!(accepted_a.run_id, accepted_b.run_id); +} + +#[test] +fn handle_http_authorizes_modular_export_and_refuses_other_purposes() { + let mut service = NaruonLiveService::new(); + let allowed = sample_export(); + let ok = service.handle_http_request(&export_http(&allowed, "export-op-a")); + assert_eq!(ok.status_code, 200); + assert_eq!(ok.reason_phrase, "OK"); + assert!(ok.body.contains("purpose_bound_export_allowed")); + assert!(!ok.body.contains("token")); + + let denied = ExportAuthorizationRequest { + purpose: AnalyticalPurpose::OperationalMonitoring, + ..allowed.clone() + }; + let forbidden = service.handle_http_request(&export_http(&denied, "export-op-b")); + assert_eq!(forbidden.status_code, 403); + assert_eq!( + envelope(&forbidden.body).error_code(), + "authorization_denied" + ); + + let same_as_principal = + service.handle_http_request(&export_http(&allowed, allowed.principal_id.as_str())); + assert_eq!(same_as_principal.status_code, 400); + assert_eq!( + envelope(&same_as_principal.body).error_code(), + "invalid_wire_payload" + ); +} + +#[test] +fn handle_http_refuses_methods_paths_versions_and_table_hosts() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let body = run.to_json().expect("json"); + let headers = naruon_headers(&run.idempotency_key); + + let get = service.handle_http_request(&http_request( + "GET", + NARUON_ANALYSIS_RUN_PATH, + &headers, + &body, + )); + assert_eq!(get.status_code, 400); + + let unknown = service.handle_http_request(&http_request( + "POST", + "/v1/tables/document_record", + &headers, + &body, + )); + assert_eq!(unknown.status_code, 400); + + let sql = service.handle_http_request(&http_request("POST", "/sql", &headers, &body)); + assert_eq!(sql.status_code, 400); + + let query = service.handle_http_request(&http_request( + "POST", + "/v1/analysis-runs?drop=1", + &headers, + &body, + )); + assert_eq!(query.status_code, 400); + + assert_eq!( + service + .handle_http_request("POST /v1/analysis-runs HTTP/1.1") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&"x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT)) + .status_code, + 413 + ); + assert_eq!( + service + .handle_http_request( + "POST /v1/analysis-runs HTTP/1.1 extra\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "POST /v1/analysis-runs#drop HTTP/1.1\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request("POST /proxy://target HTTP/1.1\r\ncontent-length: 0\r\n\r\n") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "POST /v1/analysis-runs HTTP/1.1\r\ncontent-length: 0\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + + let http10 = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.0\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ); + assert_eq!(service.handle_http_request(&http10).status_code, 400); + + let mut postgres_host = headers.clone(); + postgres_host[0] = ("Host".into(), "postgres.example.test".into()); + let table_host = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &postgres_host, + &body, + )); + assert_eq!(table_host.status_code, 400); + + let mut jdbc_host = headers; + jdbc_host[0] = ("Host".into(), "jdbc.example.test".into()); + assert_eq!( + service + .handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &jdbc_host, + &body + )) + .status_code, + 400 + ); +} + +#[test] +fn handle_http_requires_loopback_host_and_refuses_transfer_encoding() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let body = run.to_json().expect("json"); + + let mut localhost_headers = naruon_headers(&run.idempotency_key); + localhost_headers[0] = ("Host".into(), "localhost".into()); + let localhost = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &localhost_headers, + &body, + )); + assert_eq!(localhost.status_code, 202); + + for host in ["attacker.example.com", "mysql.internal", "8.8.8.8"] { + let mut headers = naruon_headers(&run.idempotency_key); + headers[0] = ("Host".into(), host.into()); + let response = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &headers, + &body, + )); + assert_eq!(response.status_code, 403, "host={host}"); + assert_eq!( + envelope(&response.body).error_code(), + "authorization_denied" + ); + } + + let mut chunked = naruon_headers(&run.idempotency_key); + chunked.push(("Transfer-Encoding".into(), "chunked".into())); + let transfer = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &chunked, + &body, + )); + assert_eq!(transfer.status_code, 400); + assert_eq!( + envelope(&transfer.body).error_code(), + "invalid_wire_payload" + ); +} + +#[test] +fn handle_http_refuses_credential_headers_and_reserved_overrides() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let body = run.to_json().expect("json"); + for (name, value, status, code) in [ + ( + "Authorization", + "Bearer review-agent", + 403, + "authorization_denied", + ), + ("cookie", "a=b", 403, "authorization_denied"), + ("x-api-key", "k", 403, "authorization_denied"), + ("x-github-token", "t", 403, "authorization_denied"), + ("x-copilot-session", "s", 403, "authorization_denied"), + ( + "Proxy-Authorization", + "Basic review-agent", + 403, + "authorization_denied", + ), + ( + "x-nvidia-nim-key", + "nvapi-example", + 403, + "authorization_denied", + ), + ("content-type", "text/plain", 400, "invalid_wire_payload"), + ("tepp-consumer", "hostile", 400, "invalid_wire_payload"), + ("tepp-contract-version", "0", 400, "invalid_wire_payload"), + ("idempotency-key", "", 400, "invalid_wire_payload"), + ] { + let mut headers = naruon_headers(&run.idempotency_key); + if name.eq_ignore_ascii_case("content-type") + || name.eq_ignore_ascii_case("tepp-consumer") + || name.eq_ignore_ascii_case("tepp-contract-version") + || name.eq_ignore_ascii_case("idempotency-key") + { + headers.retain(|(existing, _)| !existing.eq_ignore_ascii_case(name)); + } + headers.push((name.into(), value.into())); + let response = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &headers, + &body, + )); + assert_eq!(response.status_code, status, "header={name}"); + assert_eq!(envelope(&response.body).error_code(), code, "header={name}"); + assert!(!response.body.contains("Bearer")); + assert!(!response.body.contains("ghs_")); + assert!(!response.body.contains("nvapi-")); + } +} + +#[test] +fn handle_http_maps_wire_version_and_limit_errors() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let unsupported = r#"{"contract_version":9,"idempotency_key":"naruon-live-idem-001","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"m","output_profile":"o"}"#; + let version = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + unsupported, + )); + assert_eq!(version.status_code, 422); + assert_eq!( + envelope(&version.body).error_code(), + "unsupported_contract_version" + ); + + let oversized = "x".repeat(DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1); + let limited = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + &oversized, + )); + assert_eq!(limited.status_code, 413); + assert_eq!(envelope(&limited.body).error_code(), "limit_exceeded"); + + let not_rfc3339 = r#"{"contract_version":1,"idempotency_key":"naruon-live-idem-001","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"k","model_contract_version":"m","output_profile":"o"}"#; + let cutoff = service.handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers(&run.idempotency_key), + not_rfc3339, + )); + assert_eq!(cutoff.status_code, 400); +} + +#[test] +fn handle_http_refuses_malformed_framing_and_header_limits() { + let mut service = NaruonLiveService::new(); + assert_eq!(service.handle_http_request("").status_code, 400); + assert_eq!( + service + .handle_http_request("POST /v1/analysis-runs HTTP/1.1\n\n") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request("POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request("NOT-A-REQUEST-LINE\r\n\r\n") + .status_code, + 400 + ); + + let mut too_many = naruon_headers("idem-many"); + for index in 0..=NARUON_LIVE_HEADER_COUNT_LIMIT { + too_many.push((format!("x-extra-{index}"), "1".into())); + } + let crowded = http_request("POST", NARUON_ANALYSIS_RUN_PATH, &too_many, "{}"); + assert_eq!(service.handle_http_request(&crowded).status_code, 413); + + let huge_name = "x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT + 8); + let huge = format!("POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\n{huge_name}: 1\r\n\r\n"); + assert_eq!(service.handle_http_request(&huge).status_code, 413); + + let mismatch = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 4\r\n\r\nab" + ); + assert_eq!(service.handle_http_request(&mismatch).status_code, 400); + + let lf_header = format!("POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost 127.0.0.1\r\n\r\n"); + assert_eq!(service.handle_http_request(&lf_header).status_code, 400); + + let missing_host = http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &[ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ("idempotency-key".into(), "k".into()), + ], + "{}", + ); + assert_eq!(service.handle_http_request(&missing_host).status_code, 400); + + let header_idem_mismatch = { + let run = sample_run(); + http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers("other-idem"), + &run.to_json().expect("json"), + ) + }; + assert_eq!( + service + .handle_http_request(&header_idem_mismatch) + .status_code, + 400 + ); + + let mut duplicate_host = naruon_headers("dup"); + duplicate_host.push(("Host".into(), "127.0.0.1".into())); + assert_eq!( + service + .handle_http_request(&http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &duplicate_host, + "{}" + )) + .status_code, + 400 + ); +} + +#[test] +fn read_http_request_covers_transport_and_limit_errors() { + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(Vec::::new())).expect_err("eof"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::read_http_request(&mut TimeoutRead).expect_err("timeout"), + ApiError::LimitExceeded + ); + assert_eq!( + NaruonLiveService::read_http_request(&mut OtherRead).expect_err("other"), + ApiError::InvalidWirePayload + ); + let oversized = vec![b'x'; NARUON_LIVE_HEADER_BYTE_LIMIT + 1]; + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(oversized)).expect_err("limit"), + ApiError::LimitExceeded + ); + + let run = sample_run(); + let request = analysis_http(&run); + let parsed = + NaruonLiveService::read_http_request(&mut Cursor::new(request.as_bytes())).expect("read"); + assert_eq!(parsed, request); + + let mut failing = Cursor::new(Vec::::new()); + let response = NaruonLiveService::new().handle_http_request(&request); + assert_eq!( + NaruonLiveService::write_response(&mut FailingWriter, &response).expect_err("write"), + ApiError::InvalidWirePayload + ); + assert_eq!( + NaruonLiveService::write_response(&mut FlushFailWriter, &response).expect_err("flush"), + ApiError::InvalidWirePayload + ); + NaruonLiveService::write_response(&mut failing, &response).expect("ok write"); + assert!(failing.into_inner().starts_with(b"HTTP/1.1 202")); + + let mut invalid_utf8 = b"POST /v1/analysis-runs HTTP/1.1\r\n".to_vec(); + invalid_utf8.push(0xff); + invalid_utf8.extend_from_slice(b"\r\n\r\n"); + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(invalid_utf8)).expect_err("utf8"), + ApiError::InvalidWirePayload + ); + + let mut invalid_body = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 1\r\n\r\n".to_vec(); + invalid_body.push(0xff); + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(invalid_body)) + .expect_err("body utf8"), + ApiError::InvalidWirePayload + ); + + let zero = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 0\r\n\r\n"; + assert!(NaruonLiveService::read_http_request(&mut Cursor::new(zero.as_slice())).is_ok()); + assert!(NaruonLiveService::read_http_request(&mut Cursor::new(zero.to_vec())).is_ok()); + + let truncated = b"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: k\r\ncontent-length: 4\r\n\r\nab"; + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(truncated.as_slice())) + .expect_err("short body"), + ApiError::InvalidWirePayload + ); + + let huge_len = format!( + "POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-length: {}\r\n\r\n", + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT + 1 + ); + assert_eq!( + NaruonLiveService::read_http_request(&mut Cursor::new(huge_len.into_bytes())) + .expect_err("declared limit"), + ApiError::LimitExceeded + ); +} + +#[test] +fn serve_one_accepts_committed_naruon_exchange_over_loopback_tcp() { + let run = sample_run(); + let exchange = naruon_analysis_run_exchange("https://tepp.example.test", &run).expect("ex"); + let mut service = NaruonLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let worker = thread::spawn(move || service.serve_one()); + + let mut stream = TcpStream::connect(addr).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("rt"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("wt"); + let mut headers = naruon_headers(&run.idempotency_key); + headers[0] = ("Host".into(), format!("{addr}")); + for extra in &exchange.headers { + if extra.0 == "content-type" + || extra.0 == "tepp-consumer" + || extra.0 == "tepp-contract-version" + || extra.0 == "idempotency-key" + { + continue; + } + headers.push(extra.clone()); + } + let payload = http_request("POST", NARUON_ANALYSIS_RUN_PATH, &headers, &exchange.body); + stream.write_all(payload.as_bytes()).expect("write"); + let mut received = String::new(); + stream.read_to_string(&mut received).expect("read"); + assert!(received.starts_with("HTTP/1.1 202 Accepted")); + assert!(received.contains("\"run_state\":\"accepted\"")); + let served = worker.join().expect("join").expect("serve"); + assert_eq!(served.status_code, 202); + + let mut idle_listener = NaruonLiveService::bind_loopback().expect("bind2"); + let idle_addr = idle_listener.local_addr().expect("addr2"); + let idle_worker = thread::spawn(move || idle_listener.serve_one()); + drop(TcpStream::connect(idle_addr).expect("connect2")); + let idle_response = idle_worker.join().expect("join2").expect("served closed"); + assert_eq!(idle_response.status_code, 400); + + let mut empty_listener = NaruonLiveService::bind_loopback().expect("bind3"); + let empty_addr = empty_listener.local_addr().expect("addr3"); + let empty_worker = thread::spawn(move || empty_listener.serve_one()); + let mut empty_stream = TcpStream::connect(empty_addr).expect("connect3"); + empty_stream + .write_all( + http_request( + "POST", + NARUON_ANALYSIS_RUN_PATH, + &naruon_headers("empty-body"), + "", + ) + .as_bytes(), + ) + .expect("write3"); + let mut empty_received = String::new(); + empty_stream + .read_to_string(&mut empty_received) + .expect("read3"); + assert!(empty_received.starts_with("HTTP/1.1 400 Bad Request")); + assert_eq!( + empty_worker + .join() + .expect("join3") + .expect("served empty") + .status_code, + 400 + ); +} + +#[test] +fn serve_one_authorizes_export_over_loopback_tcp() { + let request = sample_export(); + let exchange = naruon_export_exchange("https://tepp.example.test", &request, "export-tcp-001") + .expect("ex"); + let mut service = NaruonLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let worker = thread::spawn(move || service.serve_one()); + + let mut stream = TcpStream::connect(addr).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("rt"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("wt"); + let mut headers = naruon_headers("export-tcp-001"); + headers[0] = ("Host".into(), format!("{addr}")); + let payload = http_request("POST", NARUON_EXPORT_PATH, &headers, &exchange.body); + stream.write_all(payload.as_bytes()).expect("write"); + let mut received = String::new(); + stream.read_to_string(&mut received).expect("read"); + assert!(received.starts_with("HTTP/1.1 200 OK")); + assert!(received.contains("purpose_bound_export_allowed")); + let served = worker.join().expect("join").expect("serve"); + assert_eq!(served.status_code, 200); +} + +#[test] +fn serve_one_maps_partial_request_timeout_to_limit_exceeded() { + let mut service = NaruonLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let worker = thread::spawn(move || service.serve_one()); + let stream = TcpStream::connect(addr).expect("connect"); + let started = Instant::now(); + let served = worker.join().expect("join").expect("timeout mapped"); + drop(stream); + assert!(started.elapsed() >= NARUON_LIVE_IO_TIMEOUT); + assert_eq!(served.status_code, 413); + assert_eq!(envelope(&served.body).error_code(), "limit_exceeded"); +} + +struct TimeoutRead; + +impl Read for TimeoutRead { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout")) + } +} + +struct OtherRead; + +impl Read for OtherRead { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("broken")) + } +} + +struct FailingWriter; + +impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other("write failed")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +struct FlushFailWriter; + +impl Write for FlushFailWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("flush failed")) + } +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 95a1b64fe..8980f1ded 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,13 +1,13 @@ # TEPP API and Modular Integration Contract **Status:** Accepted target contract; exact endpoints are introduced only with executable services. -**Last reviewed:** 2026-08-13 +**Last reviewed:** 2026-08-16 ## 1. Authority boundary TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts, not a production HTTP service. Endpoint examples below are target interface shapes and must not be presented as deployed behavior until implemented and tested. +Current protected main exposes Rust library/domain contracts. The active PR adds a loopback HTTP/1.1 listener for naruon analysis-run and export POSTs. That listener is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` remain target interface shapes. ## 2. Contract families @@ -143,4 +143,4 @@ Cross-format exports (JSON-LD, GraphML, CSV, Arrow/Parquet, SVG/PDF) must be sem ## 11. Compatibility tests -Consumer/provider contract tests must cover version negotiation, unknown fields, size/depth limits, idempotency, stale/invalid snapshot identity, future evidence, tenant/purpose denial, cancellation, retry semantics, artifact digest mismatch, and graceful handling of unsupported model/language capabilities. \ No newline at end of file +Consumer/provider contract tests must cover version negotiation, unknown fields, size/depth limits, idempotency, stale/invalid snapshot identity, future evidence, tenant/purpose denial, cancellation, retry semantics, artifact digest mismatch, and graceful handling of unsupported model/language capabilities. diff --git a/docs/LLM_ORCHESTRATION.md b/docs/LLM_ORCHESTRATION.md index 98102aa5f..c58ba5069 100644 --- a/docs/LLM_ORCHESTRATION.md +++ b/docs/LLM_ORCHESTRATION.md @@ -1,6 +1,6 @@ # TEPP LLM Orchestration and Test-Time Compute Contract -**Status:** Partial — `tepp_api::route_orchestration` is the governed selector; live provider execution is not yet shipped. +**Status:** Partial — `tepp_api::route_orchestration` is the governed selector; live provider execution is not yet shipped. **Last reviewed:** 2026-08-13 ## 1. Purpose @@ -132,4 +132,4 @@ Before claiming an orchestration mode materially improves TEPP, compare at least 4. adaptive/learned-conductor-style workflow where available; 5. at least two reasoning-effort/budget settings. -Report uncertainty and failure modes, not only the best benchmark score. \ No newline at end of file +Report uncertainty and failure modes, not only the best benchmark score. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index f67396413..a3e674cfb 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -36,7 +36,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | `tepp_api` export authorization plus provider-payload minimization / elevated re-identification implemented-main; persistence retention/deletion remaining | partial | | tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `tepp_api` time-bounded `PurposeGrant` + cross-tenant denial implemented-main; persistent `access_grant` storage remaining | partial | | standalone + modular CWL MSA / no cross-service DB coupling | ADR 0011; `docs/API_CONTRACT.md` | current standalone crates; future service ports | partial | -| naruon modular artifact consumer boundary | ADR 0011/0012; API contract | `docs/connectors/naruon-artifact-consumer.md` + PR #22 versioned consumer contract on protected main; `tepp_api` HTTP interchange (active PR); live HTTP service remaining | partial | +| naruon modular artifact consumer boundary | ADR 0011/0012; API contract | `docs/connectors/naruon-artifact-consumer.md` + PR #22 versioned consumer contract on protected main; `tepp_api` HTTP interchange (PR #42 implemented-main); loopback live listener on the active PR; production TLS remaining | partial | | contextual-orchestrator interpretation port boundary | ADR 0010/0011; LLM orchestration | `docs/connectors/contextual-orchestrator-interpretation-port.md`; live port remaining | partial | | Actions registry identities bound to protected-main tree (orphan disable) | Operability; GitHub Actions REST | `scripts/actions_workflow_fleet.py` + issue #20 tests/doctoring; live disable remains operator-authorized | active-PR | | autonomous model proposal separated from verification/publication/review/merge | ADR 0015 | future safe OpenCode/NVIDIA autonomous-development workflow | accepted-target | diff --git a/docs/adr/0009-purpose-bound-pii-governance.md b/docs/adr/0009-purpose-bound-pii-governance.md index b705b886b..88ed13413 100644 --- a/docs/adr/0009-purpose-bound-pii-governance.md +++ b/docs/adr/0009-purpose-bound-pii-governance.md @@ -1,7 +1,7 @@ # ADR 0009 — Purpose-bound PII governance without blanket masking **Decision status:** Accepted -**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) is implemented-main; purpose-bound provider-payload minimization (expired-purpose denial, log/source separation, separately authorized re-identification) is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target +**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) is implemented-main; purpose-bound provider-payload minimization (expired-purpose denial, log/source separation, separately authorized re-identification) is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target **Date:** 2026-08-10 **Supersedes:** None. diff --git a/docs/adr/0010-adaptive-llm-orchestration.md b/docs/adr/0010-adaptive-llm-orchestration.md index d04bd6bce..a33789839 100644 --- a/docs/adr/0010-adaptive-llm-orchestration.md +++ b/docs/adr/0010-adaptive-llm-orchestration.md @@ -1,7 +1,7 @@ # ADR 0010 — Adaptive LLM orchestration and test-time compute **Decision status:** Accepted -**Implementation maturity:** partial — `tepp_api` governed router, comparable-budget ablation record, and credential-free contextual-orchestrator binding are implemented on the active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; live NIM execution, learned conductor calibration, and production ablation evidence remain accepted-target +**Implementation maturity:** partial — `tepp_api` governed router, comparable-budget ablation record, and credential-free contextual-orchestrator binding are implemented on the active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; live NIM execution, learned conductor calibration, and production ablation evidence remain accepted-target **Date:** 2026-08-10 **Supersedes:** The LLM orchestration-selection/ablation clauses previously co-located in ADR 0006. ADR 0006 remains authoritative for GPU/VRAM and model-credential separation; ADR 0015 governs autonomous repository-write/review/merge authority. diff --git a/docs/adr/0011-standalone-modular-msa-boundary.md b/docs/adr/0011-standalone-modular-msa-boundary.md index d0572fc25..d545ee23f 100644 --- a/docs/adr/0011-standalone-modular-msa-boundary.md +++ b/docs/adr/0011-standalone-modular-msa-boundary.md @@ -1,7 +1,7 @@ # ADR 0011 — Standalone operation and modular CWL MSA boundary **Decision status:** Accepted -**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access and credential headers) implemented on the active PR (not implemented-main); live HTTP service and remaining production persistence integrations remain accepted-target +**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target **Date:** 2026-08-10 **Supersedes:** The broad cross-service ownership wording in ADR 0001. ADR 0001 remains authoritative for Rust-first numerical architecture. diff --git a/docs/connectors/contextual-orchestrator-interpretation-port.md b/docs/connectors/contextual-orchestrator-interpretation-port.md index 377c91c3c..614a4a974 100644 --- a/docs/connectors/contextual-orchestrator-interpretation-port.md +++ b/docs/connectors/contextual-orchestrator-interpretation-port.md @@ -1,6 +1,6 @@ # contextual-orchestrator interpretation port for TEPP -**Status:** Partial modular integration contract — `tepp_api::bind_contextual_orchestrator` is the credential-free binding; live HTTP remains accepted-target. +**Status:** Partial modular integration contract — `tepp_api::bind_contextual_orchestrator` is the credential-free binding; live HTTP remains accepted-target. **Last reviewed:** 2026-08-13 ## Boundary diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 4044b31d4..2e4f4d6c0 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -1,7 +1,7 @@ # naruon modular consumer contract for TEPP artifacts -**Status:** Partial — versioned DTO plus HTTP interchange on the active PR; live HTTP service remaining -**Last reviewed:** 2026-08-13 +**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener on the active PR; production TLS/`$PORT` remaining +**Last reviewed:** 2026-08-16 ## Boundary @@ -25,6 +25,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | purpose-bound export auth | `tepp_api` `authorize_export` with `ModularServiceConsumer` | TEPP gate | | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | +| Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | Committed examples live under `examples/`. Schema for analysis-run requests lives under `schemas/analysis_run_request_v1.json`. @@ -39,7 +40,9 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic - knowledge cutoff / availability violations → reject in TEPP domain crates; - authorization deny → `authorization_denied` envelope without policy leakage; - `postgres` / `jdbc` / `/sql` / `/tables/` or non-`https` origins → reject; -- review, Copilot, or bearer credential headers → reject; +- review, Copilot, NIM/NVIDIA, proxy-authorization, or bearer credential headers → reject; +- non-loopback `Host` or `Transfer-Encoding` on the live listener → reject; +- non-RFC 3339 or future-dated `knowledge_cutoff` → reject; - redefinition of reserved headers (`content-type`, `tepp-consumer`, `tepp-contract-version`, `idempotency-key`) via extra headers → reject; - export interchange without a nonempty per-export idempotency key → reject; @@ -47,7 +50,9 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic ## Authority sources -Fielding, R. T., & Reschke, J. (Eds.). (2014). *Hypertext Transfer Protocol (HTTP/1.1): Semantics and content* (RFC 7231). IETF. https://doi.org/10.17487/RFC7231 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). IETF. https://doi.org/10.17487/RFC3339 ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. diff --git a/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md index 652acfe21..a7edc9f8e 100644 --- a/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md +++ b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md @@ -20,6 +20,18 @@ run may print the task contract without either credential. When a PR exists, normal review → repair → exact-head Checks → merge governance owns the hour. The scheduler does not create a competing branch. +Current executable queue while drafts remain open: + +1. Merge the predicted-versus-observed Allen coverage gate + (`prediction_contradiction` / the coverage-authority landing PR). Keep + superseded coverage drafts unmerged. +2. Next buyer-visible slice: naruon live HTTP loopback with stream deadline, + RFC 3339 cutoff, loopback `Host`, NIM/proxy header refusal, and export + over a real `TcpStream` (this PR). Keep PR #87 unmerged. +3. After that: `text_segment` SQL on migration `0006` (PR #99), then + production TLS bind (PR #100 / #90). Do not open a competing hourly + proposal until the open-PR inventory is empty. + ## Required repository configuration Configure these repository or organization values: diff --git a/docs/research/naruon-http-interchange.md b/docs/research/naruon-http-interchange.md index 090df0289..a8245ebdb 100644 --- a/docs/research/naruon-http-interchange.md +++ b/docs/research/naruon-http-interchange.md @@ -3,26 +3,31 @@ ## Scope naruon may submit analysis-run requests and request purpose-bound exports only -through versioned `https` POST paths owned by TEPP. HTTP method, path, and -header semantics for that interchange follow HTTP/1.1 (Fielding & Reschke, -2014). Fail-closed refusal of table-access URLs, review/Copilot credential -headers, reserved-header redefinition, principal-only idempotency keys, and -lexical TEPP inference claims is repository contract authority (see Internal -contract evidence), not an RFC inference rule. - -This is not a live HTTP server. Persistence remains TEPP-owned; naruon never -migrates or queries TEPP application tables. Purpose-bound export disclosure -and privacy-management readiness map to published privacy guidance (ISO/IEC, -2019; National Institute of Standards and Technology, 2020) without claiming -certification. +through versioned `https` POST paths owned by TEPP. HTTP method, path, `Host`, +and `Transfer-Encoding` semantics follow current HTTP semantics (Fielding, +Nottingham, & Reschke, 2022). Knowledge-cutoff instants use RFC 3339 +(Klyne & Newman, 2002). Fail-closed refusal of table-access URLs, +review/Copilot/NIM/proxy credential headers, reserved-header redefinition, +principal-only idempotency keys, and lexical TEPP inference claims is +repository contract authority (see Internal contract evidence), not an RFC +inference rule. + +The live listener is loopback HTTP/1.1 with an installed read/write deadline. +It is not a production TLS/`$PORT` service. Persistence remains TEPP-owned; +naruon never migrates or queries TEPP application tables. Purpose-bound export +disclosure and privacy-management readiness map to published privacy guidance +(ISO/IEC, 2019; National Institute of Standards and Technology, 2020) without +claiming certification. ## Authority ### External standards (HTTP and privacy claims only) -Fielding, R. T., & Reschke, J. (Eds.). (2014). *Hypertext Transfer Protocol -(HTTP/1.1): Semantics and content* (RFC 7231). IETF. -https://doi.org/10.17487/RFC7231 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* +(RFC 3339). IETF. https://doi.org/10.17487/RFC3339 ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — @@ -38,13 +43,18 @@ https://doi.org/10.6028/NIST.CSWP.01162020 - `docs/API_CONTRACT.md` — versioned analysis-run and export surfaces - `docs/adr/0011-standalone-modular-msa-boundary.md` — no cross-service table access - `crates/tepp_api/tests/naruon_http_contract.rs` — fail-closed interchange proofs +- `crates/tepp_api/tests/naruon_live_http_contract.rs` — loopback TCP, deadline, Host, cutoff ## Verification - committed naruon example builds `POST /v1/analysis-runs` without credentials; - `postgres` / `jdbc` / `/sql` / `/tables/` and non-`https` origins fail closed; -- review, Copilot, and bearer headers are `AuthorizationDenied`; +- review, Copilot, NIM/NVIDIA, proxy-authorization, and bearer headers are + `AuthorizationDenied`; - reserved standard headers cannot be redefined via extra headers; +- live `Host` must be loopback; `Transfer-Encoding` is refused; +- `knowledge_cutoff` must be RFC 3339 and must not be after request receipt; +- analysis-run idempotency replay is keyed by tenant plus key; - export interchange requires `ModularServiceConsumer` and a per-export - idempotency key distinct from `principal_id` alone; + idempotency key distinct from `principal_id` alone, proven over TCP; - `tfidf` / `bm25` / `keyword` cannot claim TEPP inference. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index bfda7a795..28e62d5c9 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -134,6 +134,14 @@ TEPP applies ISO/IEC 27701:2025 purpose limitation and disclosure minimization, TEPP uses these sources as management/risk/readiness inputs, not as self-certification authority. ISO/IEC 42001:2023 and ISO/IEC 23894:2023 are published international standards (International Organization for Standardization, 2023a, 2023b). NIST AI RMF 1.0 remains the published framework while NIST is preparing a revision (Tabassi, 2023; National Institute of Standards and Technology, n.d.); the repository tracks the revision but does not silently treat an unpublished successor as normative. AICPA Trust Services Criteria are readiness inputs rather than self-issued attestation (American Institute of Certified Public Accountants, 2023). KISA currently describes CSAP service types as IaaS, SaaS, and DaaS and grades as high, medium, and low, while noting that the high and medium grades await later implementation (한국인터넷진흥원, n.d.). CSAP and SOC 2 evidence depend on actual deployment/organization controls and independent assessment. +## HTTP interchange and timestamp authority + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). IETF. https://doi.org/10.17487/RFC3339 + +TEPP uses RFC 9110 for live `Host` and `Transfer-Encoding` refusal on the naruon loopback listener, and RFC 3339 via `temporal_core::KnowledgeCutoff` so a buyer cannot submit `"k"` or a future-dated cutoff as an analysis-run clock. + ## Security, accessibility, and software supply chain World Wide Web Consortium. (2023). *Web content accessibility guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ diff --git a/docs/research/task-12-versioned-api-contracts.md b/docs/research/task-12-versioned-api-contracts.md index ecc175bd4..f8f71e409 100644 --- a/docs/research/task-12-versioned-api-contracts.md +++ b/docs/research/task-12-versioned-api-contracts.md @@ -13,13 +13,13 @@ Task 12 introduces fail-closed versioned wire contracts in `tepp_api` for standa 7. purpose-bound export authorization that preserves scientific identity linkages and refuses blanket PII masking; 8. purpose-bound provider-payload minimization with expired-grant denial and separately authorized re-identification. -HTTP service routing remains accepted-target. Domain estimation and persistence stay outside this crate. +A loopback live HTTP/1.1 listener proves analysis-run and export POSTs. Production TLS/`$PORT` routing remains accepted-target. Domain estimation and persistence stay outside this crate. ## Authoritative sources -Fielding, R. T., & Reschke, J. (Eds.). (2014). *Hypertext Transfer Protocol (HTTP/1.1): Semantics and content* (RFC 7231). IETF. https://doi.org/10.17487/RFC7231 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 -Nottingham, M. (2022). *HTTP Semantics* (RFC 9110). IETF. https://doi.org/10.17487/RFC9110 +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). IETF. https://doi.org/10.17487/RFC3339 Sporny, M., Longley, D., Kellogg, G., Lanthaler, M., Champin, P.-A., & Lindström, N. (2020). *JSON-LD 1.1* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/json-ld11/