diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d331df5fd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "rust-toolchain" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be1bd6096..3a60ca799 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,6 +12,7 @@ This file is the canonical product-wide topology and bounded-context view. It is - [Requirement, decision, standards, and implementation traceability](docs/traceability/README.md) - [Research and standards doctoring](docs/doctoring.md) - [Product roadmap](docs/product-roadmap.md) +- [Live product and technical gap baseline](docs/product-technical-gap-baseline.md) Protected-main code and executable tests define current implementation truth; deployed build/release artifacts, migrations, and configuration are additional operational evidence when they exist. Accepted ADRs define design authority, not proof that planned behavior has shipped. The PRD/TRD/diagrams may also contain `Planned`, `Proposed`, or `Open` product direction; those labels must remain explicit until corresponding implementation and review evidence reaches protected `main`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5598829e7..c41fa3705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,9 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. +- Kept the real loopback WebDriver BiDi opening-write regression test fail-fast with test-only diagnostics, while explicitly covering successful and panicked server-thread handoffs so strict all-target Clippy and exact coverage remain clean. +- Kept the loopback peer alive until opening-write timeout cleanup completes, removing a macOS close race that could report `EINVAL` after a successful request write without weakening production cleanup failures. +- Kept the revoked-stream fixture peer alive until local shutdown and fail-closed write classification complete, removing a macOS `ENOTCONN` race from the coverage path. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..848cb7320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,9 +263,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "originweave-bap" +version = "0.1.0" + [[package]] name = "originweave-core" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "originweave-destination" @@ -554,6 +561,21 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" @@ -566,6 +588,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..0d5ab469c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", "crates/originweave-evidence", diff --git a/README.md b/README.md index 17085c05d..0942976cf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OriginWeave is a Chromium-compatible, Rust-first control plane for governed AI agents on the web. It is designed to let an agent observe, extract, and act without turning untrusted page content into authority, exposing secrets to a model, connecting to an unapproved network destination, accepting an unauthenticated web service, or losing the evidence required to explain what happened. -> Project status: pre-alpha. The current repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. +> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, authenticated TLS service-identity, and bounded MCP `2026-07-28` stateless `tools/call` routing/policy foundations. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #170 implements only conservative `tools/list` discovery metadata on top of the protected-main MCP catalog; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. ## Why OriginWeave @@ -40,6 +40,8 @@ The repository is organized as independently consumable Rust crates: - `originweave-resource`: task-level RAM, VRAM, thread, and frame-time budgets with cumulative mitigation plans. - `originweave-evidence`: universally value-redacted network evidence and source-bound provenance records. +Protected main additionally contains an `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That shipped foundation validates and maps an explicit tool name to an existing typed action while preserving normal OriginWeave policy. Active PR #170 adds non-shipped conservative `tools/list` discovery metadata derived from the same reviewed catalog. Neither boundary implements transport parsing, OAuth, browser control, secret materialization, persistence, or ambient authority. + See [ARCHITECTURE.md](ARCHITECTURE.md) and the [architecture decision records](docs/adr/) for binding design decisions. ## Safety model @@ -97,7 +99,7 @@ isolated Chromium session → redacted provenance bundle ``` -Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, MCP and Browser Agent Protocol adapters, extension compatibility testing, GPU/RAM telemetry, prompt-injection benchmarks, and an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). +Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the protected-main `tools/call` foundation and active `tools/list` refinement, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). ## Hourly product-development loop @@ -109,4 +111,4 @@ Read [AGENTS.md](AGENTS.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SECURITY.m ## License -Apache License 2.0. See [LICENSE](LICENSE). +Apache License 2.0. See [LICENSE](LICENSE). \ No newline at end of file diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml new file mode 100644 index 000000000..39e8e38f7 --- /dev/null +++ b/crates/originweave-bap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "originweave-bap" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[lints] +workspace = true diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs new file mode 100644 index 000000000..404a88c10 --- /dev/null +++ b/crates/originweave-bap/src/lib.rs @@ -0,0 +1,329 @@ +//! Stable internal Browser Agent Protocol lifecycle contracts. +//! +//! This crate intentionally owns no transport, browser, network, model, secret, +//! approval, or persistence authority. External protocol adapters may project +//! these states, but protocol metadata cannot mint or change OriginWeave task +//! authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +/// Durable logical state of one governed BAP task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskState { + /// The task record exists but has not entered admission control. + Created, + /// Admission control accepted the task but execution has not started. + Admitted, + /// The task is actively executing governed work. + Running, + /// Execution is suspended until an approval decision is available. + WaitingForApproval, + /// Execution is suspended until required external input is available. + WaitingForExternalInput, + /// Execution is suspended at a compatible recoverable checkpoint. + Checkpointed, + /// Execution is suspended until an explicit reconciliation decision is recorded. + /// + /// The lifecycle state does not itself persist or authenticate reconciliation + /// evidence. A durable owner must preserve the complete evidence that caused + /// the task to enter this state before resolution is considered. + ReconciliationRequired, + /// The declared post-condition completed successfully. + Succeeded, + /// The task reached a terminal execution failure. + Failed, + /// Cancellation completed and the task cannot resume. + Cancelled, + /// The task exceeded its allowed lifetime and cannot resume. + Expired, + /// The task was terminally removed from automatic execution after governed handling. + /// + /// Durable dead-letter evidence remains the responsibility of the persistence + /// boundary; this in-memory marker must not be treated as the evidence itself. + DeadLettered, +} + +impl BapTaskState { + /// Return whether this state is final and must never transition again. + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired | Self::DeadLettered + ) + } +} + +/// One requested task-lifecycle event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskEvent { + /// Admit a newly created task. + Admit, + /// Start an admitted task. + Start, + /// Suspend a running task until approval is available. + WaitForApproval, + /// Suspend a running task until external input is available. + WaitForExternalInput, + /// Suspend a running task at a recoverable checkpoint. + Checkpoint, + /// Resume a normal suspended task into governed execution. + Resume, + /// Suspend a running task because its external outcome requires reconciliation. + RequireReconciliation, + /// Explicitly resolve a reconciliation hold and return the task to governed execution. + ResolveReconciliation, + /// Terminally remove a running or reconciliation-held task from automatic execution. + DeadLetter, + /// Record successful completion after the declared post-condition is verified. + Succeed, + /// Record terminal task failure. + Fail, + /// Record terminal cancellation. + Cancel, + /// Record terminal expiry. + Expire, +} + +/// A fail-closed lifecycle transition failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskTransitionError { + /// The requested event is not valid from the current non-terminal state. + InvalidTransition { + /// Current state that rejected the event. + from: BapTaskState, + /// Event that was rejected. + event: BapTaskEvent, + }, + /// The lifecycle sequence reached its maximum representable value. + SequenceExhausted, + /// A terminal task cannot be reopened or mutated by lifecycle events. + TerminalState { + /// Final state that rejected all further events. + state: BapTaskState, + }, +} + +impl std::fmt::Display for BapTaskTransitionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidTransition { from, event } => { + write!( + formatter, + "BAP task event {event:?} is invalid from state {from:?}" + ) + } + Self::SequenceExhausted => { + write!(formatter, "BAP task transition sequence is exhausted") + } + Self::TerminalState { state } => { + write!(formatter, "BAP task state {state:?} is terminal") + } + } + } +} + +impl std::error::Error for BapTaskTransitionError {} + +/// A fail-closed lifecycle recovery failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskRestoreError { + /// The supplied state and transition sequence cannot arise from this state machine. + InvalidSnapshot { + /// Logical state supplied by the durable recovery boundary. + state: BapTaskState, + /// Last accepted transition sequence supplied by the durable recovery boundary. + transition_sequence: u64, + }, +} + +impl std::fmt::Display for BapTaskRestoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSnapshot { + state, + transition_sequence, + } => write!( + formatter, + "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" + ), + } + } +} + +impl std::error::Error for BapTaskRestoreError {} + +/// Immutable receipt for one accepted in-memory lifecycle transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskTransition { + previous_state: BapTaskState, + current_state: BapTaskState, + sequence: u64, +} + +impl BapTaskTransition { + /// Return the state before the accepted transition. + #[must_use] + pub const fn previous_state(self) -> BapTaskState { + self.previous_state + } + + /// Return the state after the accepted transition. + #[must_use] + pub const fn current_state(self) -> BapTaskState { + self.current_state + } + + /// Return the monotonic transition sequence for this lifecycle instance. + #[must_use] + pub const fn sequence(self) -> u64 { + self.sequence + } +} + +/// Deterministic fail-closed BAP task-lifecycle kernel. +/// +/// This value is intentionally an in-memory state-transition primitive. A +/// durable repository must persist accepted transitions and impose its own +/// bounded sequence/retention contract before commercial task recovery can be +/// claimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskLifecycle { + state: BapTaskState, + transition_sequence: u64, +} + +impl Default for BapTaskLifecycle { + fn default() -> Self { + Self::new() + } +} + +impl BapTaskLifecycle { + /// Create one lifecycle in the `created` state with no accepted transitions. + #[must_use] + pub const fn new() -> Self { + Self { + state: BapTaskState::Created, + transition_sequence: 0, + } + } + + /// Restore a lifecycle state and its last accepted transition sequence. + /// + /// Recovery accepts only state/sequence pairs that are reachable through + /// this exact state machine. This prevents corrupt or stale durable metadata + /// from manufacturing an impossible execution state. + pub const fn restore( + state: BapTaskState, + transition_sequence: u64, + ) -> Result { + if !reachable_snapshot(state, transition_sequence) { + return Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence, + }); + } + Ok(Self { + state, + transition_sequence, + }) + } + + /// Return the current logical task state. + #[must_use] + pub const fn state(self) -> BapTaskState { + self.state + } + + /// Return the number of accepted lifecycle transitions. + #[must_use] + pub const fn transition_sequence(self) -> u64 { + self.transition_sequence + } + + /// Apply one reviewed lifecycle event without granting execution authority. + /// + /// Rejected events leave both state and sequence unchanged. Terminal states + /// reject every later event before evaluating any normal transition rule. + /// Reconciliation cannot use the generic `Resume` event: it requires the + /// explicit `ResolveReconciliation` event so ambiguous external outcomes + /// cannot silently re-enter execution. + pub fn apply( + &mut self, + event: BapTaskEvent, + ) -> Result { + if self.state.is_terminal() { + return Err(BapTaskTransitionError::TerminalState { state: self.state }); + } + + let next_state = match (self.state, event) { + (BapTaskState::Created, BapTaskEvent::Admit) => BapTaskState::Admitted, + (BapTaskState::Admitted, BapTaskEvent::Start) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::WaitForApproval) => { + BapTaskState::WaitingForApproval + } + (BapTaskState::Running, BapTaskEvent::WaitForExternalInput) => { + BapTaskState::WaitingForExternalInput + } + (BapTaskState::Running, BapTaskEvent::Checkpoint) => BapTaskState::Checkpointed, + ( + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed, + BapTaskEvent::Resume, + ) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { + BapTaskState::ReconciliationRequired + } + (BapTaskState::ReconciliationRequired, BapTaskEvent::ResolveReconciliation) => { + BapTaskState::Running + } + ( + BapTaskState::Running | BapTaskState::ReconciliationRequired, + BapTaskEvent::DeadLetter, + ) => BapTaskState::DeadLettered, + (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, + (_, BapTaskEvent::Fail) => BapTaskState::Failed, + (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, + (_, BapTaskEvent::Expire) => BapTaskState::Expired, + (from, event) => { + return Err(BapTaskTransitionError::InvalidTransition { from, event }); + } + }; + + let Some(sequence) = self.transition_sequence.checked_add(1) else { + return Err(BapTaskTransitionError::SequenceExhausted); + }; + let previous_state = self.state; + self.state = next_state; + self.transition_sequence = sequence; + Ok(BapTaskTransition { + previous_state, + current_state: next_state, + sequence, + }) + } +} + +const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { + match state { + BapTaskState::Created => transition_sequence == 0, + BapTaskState::Admitted => transition_sequence == 1, + BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed + | BapTaskState::ReconciliationRequired => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Succeeded => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { + transition_sequence >= 1 + } + BapTaskState::DeadLettered => transition_sequence >= 3, + } +} diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs new file mode 100644 index 000000000..01013682a --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -0,0 +1,253 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; + +#[test] +fn default_starts_a_new_created_lifecycle() { + assert_eq!(BapTaskLifecycle::default(), BapTaskLifecycle::new()); +} + +#[test] +fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { + let mut task = BapTaskLifecycle::new(); + assert_eq!(task.state(), BapTaskState::Created); + assert!(!task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 0); + + let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); + assert_eq!(admitted.previous_state(), BapTaskState::Created); + assert_eq!(admitted.current_state(), BapTaskState::Admitted); + assert_eq!(admitted.sequence(), 1); + + task.apply(BapTaskEvent::Start).expect("start"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + assert_eq!(task.state(), BapTaskState::WaitingForApproval); + + task.apply(BapTaskEvent::Resume).expect("resume approval"); + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + assert_eq!(task.state(), BapTaskState::Checkpointed); + + task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); + let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); + assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); + assert!(task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 7); +} + +#[test] +fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { + let mut task = running_task(); + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait for input"); + + let error = task + .apply(BapTaskEvent::Succeed) + .expect_err("waiting task must not skip resume and post-condition work"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::WaitingForExternalInput, + event: BapTaskEvent::Succeed, + } + ); + assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::Resume).expect("resume input"); + assert_eq!(task.state(), BapTaskState::Running); +} + +#[test] +fn invalid_transition_is_fail_closed_and_does_not_advance_history() { + let mut task = BapTaskLifecycle::new(); + + let error = task + .apply(BapTaskEvent::Start) + .expect_err("created task must be admitted first"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + } + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn terminal_task_never_reopens_or_advances_history() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + ] { + let mut task = if terminal_event == BapTaskEvent::Succeed { + running_task() + } else { + BapTaskLifecycle::new() + }; + task.apply(terminal_event).expect("enter terminal state"); + let terminal_state = task.state(); + let terminal_sequence = task.transition_sequence(); + + for later_event in [ + BapTaskEvent::Admit, + BapTaskEvent::Start, + BapTaskEvent::Resume, + BapTaskEvent::Cancel, + ] { + assert_eq!( + task.apply(later_event), + Err(BapTaskTransitionError::TerminalState { + state: terminal_state, + }) + ); + assert_eq!(task.state(), terminal_state); + assert_eq!(task.transition_sequence(), terminal_sequence); + } + } +} + +#[test] +fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { + for state in [ + BapTaskState::Created, + BapTaskState::Admitted, + BapTaskState::Running, + BapTaskState::WaitingForApproval, + BapTaskState::WaitingForExternalInput, + BapTaskState::Checkpointed, + BapTaskState::ReconciliationRequired, + ] { + for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { + let mut task = task_in_state(state); + assert_eq!(task.state(), state); + task.apply(terminal_event).expect("terminal interruption"); + assert!(task.state().is_terminal()); + } + } +} + +#[test] +fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { + let mut task = running_task(); + let required = task + .apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + assert_eq!(required.previous_state(), BapTaskState::Running); + assert_eq!( + required.current_state(), + BapTaskState::ReconciliationRequired + ); + assert!(!task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Resume, + }) + ); + assert_eq!( + task.apply(BapTaskEvent::Succeed), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Succeed, + }) + ); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::ResolveReconciliation) + .expect("resolve reconciliation"); + assert_eq!(task.state(), BapTaskState::Running); + + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation again"); + let dead_lettered = task + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter unresolved task"); + assert_eq!(dead_lettered.current_state(), BapTaskState::DeadLettered); + assert!(task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::DeadLettered, + }) + ); +} + +#[test] +fn running_task_may_dead_letter_but_pre_dispatch_task_may_not() { + let mut running = running_task(); + let transition = running + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter running task"); + assert_eq!(transition.previous_state(), BapTaskState::Running); + assert_eq!(transition.current_state(), BapTaskState::DeadLettered); + assert_eq!(transition.sequence(), 3); + assert!(running.state().is_terminal()); + + let mut created = BapTaskLifecycle::new(); + assert_eq!( + created.apply(BapTaskEvent::DeadLetter), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::DeadLetter, + }) + ); + assert_eq!(created.state(), BapTaskState::Created); + assert_eq!(created.transition_sequence(), 0); +} + +fn running_task() -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit"); + task.apply(BapTaskEvent::Start).expect("start"); + task +} + +fn task_in_state(target: BapTaskState) -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + if target == BapTaskState::Created { + return task; + } + + task.apply(BapTaskEvent::Admit).expect("admit"); + if target == BapTaskState::Admitted { + return task; + } + + task.apply(BapTaskEvent::Start).expect("start"); + match target { + BapTaskState::Running => {} + BapTaskState::WaitingForApproval => { + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait approval"); + } + BapTaskState::WaitingForExternalInput => { + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait external"); + } + BapTaskState::Checkpointed => { + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + } + BapTaskState::ReconciliationRequired => { + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + } + BapTaskState::Created + | BapTaskState::Admitted + | BapTaskState::Succeeded + | BapTaskState::Failed + | BapTaskState::Cancelled + | BapTaskState::Expired + | BapTaskState::DeadLettered => { + unreachable!("task_in_state only constructs non-terminal lifecycle states") + } + } + task +} diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs new file mode 100644 index 000000000..67deae949 --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -0,0 +1,142 @@ +#![allow(clippy::expect_used)] + +use std::error::Error as _; + +use originweave_bap::{ + BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, +}; + +#[test] +fn restored_lifecycle_preserves_state_and_monotonic_sequence() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41) + .expect("valid checkpoint snapshot"); + + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), 41); + + let resumed = task + .apply(BapTaskEvent::Resume) + .expect("resume restored task"); + assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); + assert_eq!(resumed.current_state(), BapTaskState::Running); + assert_eq!(resumed.sequence(), 42); +} + +#[test] +fn impossible_restored_snapshots_fail_closed() { + for (state, sequence) in [ + (BapTaskState::Created, 1), + (BapTaskState::Admitted, 0), + (BapTaskState::Admitted, 2), + (BapTaskState::Running, 1), + (BapTaskState::Running, 3), + (BapTaskState::WaitingForApproval, 2), + (BapTaskState::WaitingForApproval, 4), + (BapTaskState::WaitingForExternalInput, 2), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 2), + (BapTaskState::Checkpointed, 4), + (BapTaskState::ReconciliationRequired, 2), + (BapTaskState::ReconciliationRequired, 4), + (BapTaskState::Succeeded, 2), + (BapTaskState::Succeeded, 4), + (BapTaskState::Failed, 0), + (BapTaskState::Cancelled, 0), + (BapTaskState::Expired, 0), + (BapTaskState::DeadLettered, 2), + ] { + assert_eq!( + BapTaskLifecycle::restore(state, sequence), + Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence: sequence, + }), + "state={state:?}, sequence={sequence}", + ); + } +} + +#[test] +fn valid_restored_snapshot_classes_remain_accepted() { + for (state, sequence) in [ + (BapTaskState::Created, 0), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::Running, 4), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 5), + (BapTaskState::Checkpointed, 7), + (BapTaskState::ReconciliationRequired, 3), + (BapTaskState::Succeeded, 3), + (BapTaskState::Failed, 1), + (BapTaskState::Cancelled, 2), + (BapTaskState::Expired, 4), + (BapTaskState::DeadLettered, 3), + (BapTaskState::DeadLettered, 4), + ] { + let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); + assert_eq!(task.state(), state); + assert_eq!(task.transition_sequence(), sequence); + } +} + +#[test] +fn exhausted_sequence_fails_closed_without_mutating_state() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX) + .expect("valid exhausted checkpoint snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::SequenceExhausted), + ); + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), u64::MAX); +} + +#[test] +fn restored_terminal_lifecycle_remains_terminal() { + let mut task = + BapTaskLifecycle::restore(BapTaskState::Succeeded, 9).expect("valid terminal snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::Succeeded, + }), + ); + assert_eq!(task.transition_sequence(), 9); +} + +#[test] +fn lifecycle_failures_use_the_standard_rust_error_contract() { + let mut created = BapTaskLifecycle::new(); + let invalid_transition = created + .apply(BapTaskEvent::Start) + .expect_err("created task must reject start"); + assert_eq!( + invalid_transition.to_string(), + "BAP task event Start is invalid from state Created" + ); + assert!(invalid_transition.source().is_none()); + + let exhausted = BapTaskTransitionError::SequenceExhausted; + assert_eq!( + exhausted.to_string(), + "BAP task transition sequence is exhausted" + ); + assert!(exhausted.source().is_none()); + + let terminal = BapTaskTransitionError::TerminalState { + state: BapTaskState::Cancelled, + }; + assert_eq!(terminal.to_string(), "BAP task state Cancelled is terminal"); + assert!(terminal.source().is_none()); + + let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) + .expect_err("unreachable snapshot must fail"); + assert_eq!( + restore.to_string(), + "BAP task snapshot state Created with transition sequence 1 is unreachable" + ); + assert!(restore.source().is_none()); +} diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index 35c83b19b..f1273b69a 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -11,6 +11,7 @@ homepage.workspace = true publish = false [dependencies] +unicode-normalization = "=0.1.25" [lints] workspace = true diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index 88dd2e586..e33a7e7e5 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -165,6 +165,9 @@ fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), } fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } let port = port_text .parse::() .map_err(|_error| OriginError::InvalidPort)?; @@ -967,16 +970,20 @@ pub struct ExtensionAgentGrant { extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + expires_at_epoch_seconds: u64, capabilities: BTreeSet, } impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one browser session and context. + /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. #[must_use] pub fn new( extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + expires_at_epoch_seconds: u64, capabilities: I, ) -> Self where @@ -986,6 +993,8 @@ impl ExtensionAgentGrant { extension_id, browser_session, browsing_context, + origin, + expires_at_epoch_seconds, capabilities: capabilities.into_iter().collect(), } } @@ -997,22 +1006,31 @@ pub struct ExtensionAccessRequest { extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + now_epoch_seconds: u64, capability: ExtensionAgentCapability, } impl ExtensionAccessRequest { /// Build one exact extension capability request without granting authority. + /// + /// `now_epoch_seconds` must be trusted evaluation time supplied by the host, + /// not a page, extension, or model clock. #[must_use] pub const fn new( extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + now_epoch_seconds: u64, capability: ExtensionAgentCapability, ) -> Self { Self { extension_id, browser_session, browsing_context, + origin, + now_epoch_seconds, capability, } } @@ -1021,7 +1039,7 @@ impl ExtensionAccessRequest { /// Result of evaluating an extension request against one explicit Agent grant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtensionAccessDecision { - /// The exact extension, session, context, and capability are explicitly granted. + /// The exact extension, session, context, origin, unexpired grant, and capability are explicitly granted. Allow, /// No explicit extension-to-Agent grant was supplied. DenyMissingGrant, @@ -1031,6 +1049,10 @@ pub enum ExtensionAccessDecision { DenyBrowserSessionMismatch, /// The request belongs to a different independently navigable browser context. DenyBrowsingContextMismatch, + /// The request belongs to a different canonical origin than the grant. + DenyOriginMismatch, + /// Trusted evaluation time is at or after the grant's exclusive expiry. + DenyExpired, /// The extension grant does not contain the requested OriginWeave capability. DenyCapabilityNotGranted, } @@ -1039,8 +1061,9 @@ pub enum ExtensionAccessDecision { /// /// A Chrome extension permission, installation state, or page capability is never /// consulted here. A future Chromium adapter must construct a host-originated -/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context -/// request at the boundary where Agent authority would otherwise cross. +/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session, context, +/// canonical origin, and exclusive expiry at the boundary where Agent authority +/// would otherwise cross. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, @@ -1058,6 +1081,12 @@ pub fn evaluate_extension_access( if request.browsing_context != grant.browsing_context { return ExtensionAccessDecision::DenyBrowsingContextMismatch; } + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } if !grant.capabilities.contains(&request.capability) { return ExtensionAccessDecision::DenyCapabilityNotGranted; } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b9d69c8b0..9ea902c2a 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -31,6 +31,10 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +/// Stateless MCP routing validation that maps only explicit tools to typed actions. +pub mod mcp; +/// Deterministic fail-closed release benchmark acceptance aggregation. +pub mod release_acceptance; mod webdriver_bidi_command; mod webdriver_bidi_error_code; mod webdriver_bidi_response_document; diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs new file mode 100644 index 000000000..c7200e327 --- /dev/null +++ b/crates/originweave-core/src/mcp.rs @@ -0,0 +1,478 @@ +//! Fail-closed MCP routing integrity for the external adapter boundary. +//! +//! This module validates only the stateless MCP protocol/method/tool routing +//! envelope and derives an existing [`ActionKind`]. It is deliberately not an +//! authorization decision: callers must independently enforce OriginWeave +//! capability, risk, approval, origin, secret-broker, and evidence policies. +//! No MCP arguments, outputs, credentials, or arbitrary model-visible values +//! are retained by this boundary. + +use std::fmt; + +use crate::{ActionKind, Capability, RiskClass}; + +/// MCP protocol generation accepted by this stateless adapter boundary. +pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; + +/// The only MCP method that can enter the typed action-routing boundary. +pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; + +/// The MCP discovery method accepted by the typed tools-list boundary. +pub const MCP_TOOLS_LIST_METHOD: &str = "tools/list"; + +/// Maximum accepted MCP method-name length in bytes. +pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; + +/// Maximum accepted MCP tool-name length in bytes. +pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; + +/// One deterministic MCP tool descriptor derived from OriginWeave's reviewed action registry. +/// +/// The descriptor is discovery metadata only. It does not grant capabilities, origin access, +/// approval, secret access, or any other authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolCatalogEntry { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl McpToolCatalogEntry { + /// Return the canonical MCP tool name exposed by this registry entry. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the typed OriginWeave action represented by this registry entry. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } + + /// Return the capability required by the represented action. + #[must_use] + pub const fn required_capability(&self) -> Capability { + self.action_kind.required_capability() + } + + /// Return the risk class assigned to the represented action. + #[must_use] + pub const fn risk_class(&self) -> RiskClass { + self.action_kind.risk_class() + } +} + +/// The complete explicit MCP tool-to-action registry accepted by this boundary. +/// +/// Order is deterministic so adapters can derive stable discovery output from this single +/// reviewed registry rather than maintaining a second mapping that could drift from routing. +const MCP_TOOL_CATALOG: &[McpToolCatalogEntry] = &[ + McpToolCatalogEntry { + tool_name: "originweave.observe", + action_kind: ActionKind::Observe, + }, + McpToolCatalogEntry { + tool_name: "originweave.extract", + action_kind: ActionKind::Extract, + }, + McpToolCatalogEntry { + tool_name: "originweave.navigate", + action_kind: ActionKind::Navigate, + }, + McpToolCatalogEntry { + tool_name: "originweave.download", + action_kind: ActionKind::Download, + }, + McpToolCatalogEntry { + tool_name: "originweave.draft", + action_kind: ActionKind::Draft, + }, + McpToolCatalogEntry { + tool_name: "originweave.submit", + action_kind: ActionKind::Submit, + }, + McpToolCatalogEntry { + tool_name: "originweave.upload", + action_kind: ActionKind::Upload, + }, + McpToolCatalogEntry { + tool_name: "originweave.fill_secret", + action_kind: ActionKind::FillSecret, + }, + McpToolCatalogEntry { + tool_name: "originweave.purchase", + action_kind: ActionKind::Purchase, + }, + McpToolCatalogEntry { + tool_name: "originweave.delete", + action_kind: ActionKind::Delete, + }, + McpToolCatalogEntry { + tool_name: "originweave.manage_permission", + action_kind: ActionKind::ManagePermission, + }, +]; + +/// Return the deterministic reviewed MCP tool catalog. +/// +/// Adapters may use this slice to derive discovery responses. Serialization, pagination, cache +/// policy, transport I/O, and authorization remain outside this stateless registry boundary. +#[must_use] +pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { + MCP_TOOL_CATALOG +} + +/// Protocol disposition carried by a typed MCP result. +/// +/// OriginWeave currently constructs only terminal results at this boundary. A transport adapter +/// must serialize [`Self::Complete`] as MCP's `"complete"` result type and must not omit or +/// reinterpret the required protocol field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpResultType { + /// The request completed and this value contains the final result. + Complete, +} + +/// Cache-sharing scope for an MCP cacheable list result. +/// +/// OriginWeave currently exposes only the conservative private scope. A transport adapter must +/// serialize this as MCP's `"private"` cache scope and must not widen it without a separately +/// reviewed policy that proves the returned catalog is safe to share across callers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpCacheScope { + /// The result may be cached only for the current caller's private context. + Private, +} + +/// One typed MCP `tools/list` page derived from the reviewed tool catalog. +/// +/// This value is discovery metadata only. It does not grant any tool capability or action +/// authority. The initial contract is deliberately one complete private page with zero freshness +/// so adapters cannot omit MCP's required result disposition or accidentally share or reuse +/// discovery metadata beyond the current request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolsListPage { + result_type: McpResultType, + tools: &'static [McpToolCatalogEntry], + ttl_ms: u64, + cache_scope: McpCacheScope, + next_cursor: Option<&'static str>, +} + +impl McpToolsListPage { + /// Return the mandatory MCP result disposition for this list page. + #[must_use] + pub const fn result_type(&self) -> McpResultType { + self.result_type + } + + /// Return the deterministic reviewed tool entries in this page. + #[must_use] + pub const fn tools(&self) -> &'static [McpToolCatalogEntry] { + self.tools + } + + /// Return the MCP freshness lifetime in milliseconds. + /// + /// The current conservative contract is zero, so clients must treat the result as + /// immediately stale rather than reusing it for a later request. + #[must_use] + pub const fn ttl_ms(&self) -> u64 { + self.ttl_ms + } + + /// Return the MCP cache-sharing scope for this page. + #[must_use] + pub const fn cache_scope(&self) -> McpCacheScope { + self.cache_scope + } + + /// Return the opaque continuation cursor when another page exists. + /// + /// The current fixed catalog is emitted as one complete page, so this is always `None`. + #[must_use] + pub const fn next_cursor(&self) -> Option<&'static str> { + self.next_cursor + } +} + +/// Build the conservative typed MCP `tools/list` result for the reviewed catalog. +/// +/// This function does not perform transport serialization, authorization, or pagination. It +/// binds the catalog to the mandatory complete result disposition plus explicit zero-TTL/private +/// cache hints so adapters cannot invent broader protocol or cache semantics independently from +/// this reviewed boundary. +#[must_use] +pub const fn mcp_tools_list_page() -> McpToolsListPage { + McpToolsListPage { + result_type: McpResultType::Complete, + tools: MCP_TOOL_CATALOG, + ttl_ms: 0, + cache_scope: McpCacheScope::Private, + next_cursor: None, + } +} + +/// A deterministic failure while validating one MCP `tools/list` request envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolsListBoundaryError { + /// The transport request omitted the required MCP protocol-version header. + MissingProtocolVersionHeader, + /// The structured request metadata omitted the required MCP protocol version. + MissingProtocolVersionMetadata, + /// The transport protocol version disagrees with the structured request metadata. + ProtocolVersionHeaderBodyMismatch, + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// The structured request metadata omitted the required client-capabilities object. + MissingClientCapabilities, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, + /// MCP routing method metadata disagrees with the method in the request body. + MethodHeaderBodyMismatch, + /// The request method is not the supported `tools/list` operation. + UnsupportedMethod, + /// The request supplied a cursor that this fixed single-page catalog never issued. + UnsupportedCursor, +} + +impl fmt::Display for McpToolsListBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingProtocolVersionHeader => { + formatter.write_str("MCP protocol version header is required") + } + Self::MissingProtocolVersionMetadata => { + formatter.write_str("MCP request metadata protocol version is required") + } + Self::ProtocolVersionHeaderBodyMismatch => { + formatter.write_str("MCP protocol version header does not match request metadata") + } + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::MissingClientCapabilities => { + formatter.write_str("MCP request metadata client capabilities are required") + } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } + Self::MethodHeaderBodyMismatch => { + formatter.write_str("MCP method header does not match the request body") + } + Self::UnsupportedMethod => { + formatter.write_str("only MCP tools/list requests can enter the discovery boundary") + } + Self::UnsupportedCursor => { + formatter.write_str("MCP tools/list cursor was not issued by this fixed catalog") + } + } + } +} + +impl std::error::Error for McpToolsListBoundaryError {} + +/// An MCP `tools/list` request whose protocol, required metadata, and routing envelope were +/// validated. +/// +/// This boundary is deliberately narrower than a general transport or pagination implementation. +/// A trusted structured parser must prove whether the required per-request client-capabilities +/// object was present; this type never accepts its contents as authority. The current reviewed +/// catalog returns one complete page and emits no continuation cursor, so no non-null cursor can +/// be a value previously issued by OriginWeave. A transport adapter must not silently ignore or +/// reinterpret a supplied cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolsListRequest { + method: &'static str, +} + +impl ValidatedMcpToolsListRequest { + /// Validate the stateless request envelope for the current fixed `tools/list` catalog. + /// + /// Both the required transport protocol-version header and structured request `_meta` + /// protocol version must be present, individually bounded to the exact supported-version + /// length before cross-field comparison, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A + /// trusted structured parser must also attest that the required `_meta` client-capabilities + /// object was present; its contents grant no OriginWeave authority. Each untrusted method + /// value is shape-validated before comparison. The routing/body method must then agree exactly. + /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation + /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. + pub fn new( + protocol_version_header: Option<&str>, + protocol_version_metadata: Option<&str>, + client_capabilities_present: bool, + routing_method: &str, + body_method: &str, + cursor: Option<&str>, + ) -> Result { + let protocol_version_header = protocol_version_header + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionHeader)?; + let protocol_version_metadata = protocol_version_metadata + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionMetadata)?; + + if protocol_version_header.len() > MCP_PROTOCOL_VERSION.len() + || protocol_version_metadata.len() > MCP_PROTOCOL_VERSION.len() + { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if protocol_version_header != protocol_version_metadata { + return Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch); + } + if protocol_version_metadata != MCP_PROTOCOL_VERSION { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if !client_capabilities_present { + return Err(McpToolsListBoundaryError::MissingClientCapabilities); + } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolsListBoundaryError::InvalidMethod); + } + if routing_method != body_method { + return Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_LIST_METHOD { + return Err(McpToolsListBoundaryError::UnsupportedMethod); + } + if cursor.is_some() { + return Err(McpToolsListBoundaryError::UnsupportedCursor); + } + + Ok(Self { + method: MCP_TOOLS_LIST_METHOD, + }) + } + + /// Return the canonical MCP method validated by this request. + #[must_use] + pub const fn method(&self) -> &'static str { + self.method + } +} + +/// A deterministic failure while validating untrusted MCP routing metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolBoundaryError { + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// MCP routing metadata disagrees with the method or tool name in the body. + HeaderBodyMismatch, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, + /// The request method is not the supported `tools/call` operation. + UnsupportedMethod, + /// The tool name violates the bounded ASCII MCP routing syntax. + InvalidToolName, + /// The tool name has no explicit mapping to an OriginWeave typed action. + UnknownTool, +} + +impl fmt::Display for McpToolBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::HeaderBodyMismatch => { + formatter.write_str("MCP routing headers do not match the request body") + } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } + Self::UnsupportedMethod => formatter + .write_str("only MCP tools/call requests can enter the typed action boundary"), + Self::InvalidToolName => { + formatter.write_str("MCP tool name violates the bounded ASCII routing syntax") + } + Self::UnknownTool => { + formatter.write_str("MCP tool is not mapped to an OriginWeave typed action") + } + } + } +} + +impl std::error::Error for McpToolBoundaryError {} + +/// An MCP tool call whose routing envelope has been validated and mapped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolCall { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl ValidatedMcpToolCall { + /// Validate one stateless MCP tool-call routing envelope. + /// + /// Routing integrity is intentionally narrower than authorization. A + /// successful value proves only that the untrusted protocol version, + /// routing metadata, body method, and body tool name agree with one + /// explicitly supported mapping. Each untrusted method and tool name is + /// shape-validated before cross-field comparison so malformed or oversized + /// metadata cannot bypass the bounded routing syntax through mismatch handling. + pub fn new( + protocol_version: &str, + routing_method: &str, + routing_tool_name: &str, + body_method: &str, + body_tool_name: &str, + ) -> Result { + if protocol_version != MCP_PROTOCOL_VERSION { + return Err(McpToolBoundaryError::UnsupportedProtocolVersion); + } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolBoundaryError::InvalidMethod); + } + if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { + return Err(McpToolBoundaryError::InvalidToolName); + } + if routing_method != body_method || routing_tool_name != body_tool_name { + return Err(McpToolBoundaryError::HeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_CALL_METHOD { + return Err(McpToolBoundaryError::UnsupportedMethod); + } + + let (tool_name, action_kind) = map_tool(routing_tool_name)?; + Ok(Self { + tool_name, + action_kind, + }) + } + + /// Return the canonical static tool name selected by the explicit mapping. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the existing OriginWeave typed action selected by this tool. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } +} + +fn valid_method(method: &str) -> bool { + if method.is_empty() || method.len() > MAX_MCP_METHOD_NAME_BYTES { + return false; + } + method + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')) +} + +fn valid_tool_name(tool_name: &str) -> bool { + if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { + return false; + } + tool_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { + MCP_TOOL_CATALOG + .iter() + .find(|entry| entry.tool_name == tool_name) + .map(|entry| (entry.tool_name, entry.action_kind)) + .ok_or(McpToolBoundaryError::UnknownTool) +} diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs new file mode 100644 index 000000000..a3655de52 --- /dev/null +++ b/crates/originweave-core/src/release_acceptance.rs @@ -0,0 +1,368 @@ +//! Deterministic fail-closed release acceptance for commercial benchmark evidence. +//! +//! This module aggregates only explicit mandatory-suite outcomes and bounded, +//! buyer-visible limitations. It does not execute benchmarks, infer missing +//! evidence, authenticate artifacts, or grant release authority. + +use std::fmt; + +use unicode_normalization::is_nfc; + +/// Maximum UTF-8 byte length retained for either buyer-visible limitation field. +pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; + +/// Maximum number of buyer-visible limitations retained in one release report. +pub const MAX_DECLARED_RELEASE_LIMITATIONS: usize = 64; + +/// One mandatory benchmark suite in the release acceptance contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BenchmarkSuite { + /// Controlled local fixtures with deterministic post-condition oracles. + ControlledDeterministic, + /// Stable web compatibility tasks for the declared support profile. + WebCompatibility, + /// Hostile security cases that measure unauthorized authority or disclosure. + SecurityAdversarial, + /// Crash, timeout, retry, reconciliation, cleanup, and restore behavior. + ReliabilityRecovery, + /// Enterprise isolation, identity, policy, audit, and operator controls. + EnterpriseOperability, +} + +impl BenchmarkSuite { + /// Every mandatory benchmark suite in canonical release-report order. + pub const ALL: [Self; 5] = [ + Self::ControlledDeterministic, + Self::WebCompatibility, + Self::SecurityAdversarial, + Self::ReliabilityRecovery, + Self::EnterpriseOperability, + ]; + + /// Return the stable snake-case suite identifier used by benchmark evidence. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ControlledDeterministic => "controlled_deterministic_suite", + Self::WebCompatibility => "web_compatibility_suite", + Self::SecurityAdversarial => "security_adversarial_suite", + Self::ReliabilityRecovery => "reliability_recovery_suite", + Self::EnterpriseOperability => "enterprise_operability_suite", + } + } + + const fn index(self) -> usize { + match self { + Self::ControlledDeterministic => 0, + Self::WebCompatibility => 1, + Self::SecurityAdversarial => 2, + Self::ReliabilityRecovery => 3, + Self::EnterpriseOperability => 4, + } + } +} + +/// Evaluated outcome for one mandatory benchmark suite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchmarkSuiteOutcome { + /// Every threshold required for the declared profile passed. + Passed, + /// At least one mandatory threshold is known to have failed. + Failed, + /// Evidence is insufficient to establish either pass or threshold failure. + Inconclusive, +} + +/// One explicit narrowed release claim and its buyer-visible consequence. +/// +/// An accepted-with-limitations decision cannot be produced from an opaque +/// boolean. Every limitation must name the unsupported claim and state the +/// consequence that a buyer must account for in the declared support profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredLimitation { + unsupported_claim: String, + buyer_consequence: String, +} + +impl DeclaredLimitation { + /// Construct one explicit buyer-visible release limitation. + /// + /// Empty/whitespace-only or punctuation-only values, surrounding whitespace, + /// non-NFC Unicode, fields exceeding the fixed UTF-8 byte budget, and ambiguous + /// presentation characters fail closed because they cannot safely represent one + /// canonical, resource-bounded buyer-visible release limitation. Accepted text + /// is retained byte-for-byte; this constructor never normalizes caller input + /// implicitly. + pub fn new( + unsupported_claim: impl Into, + buyer_consequence: impl Into, + ) -> Result { + Self::from_owned_text(unsupported_claim.into(), buyer_consequence.into()) + } + + fn from_owned_text( + unsupported_claim: String, + buyer_consequence: String, + ) -> Result { + if unsupported_claim.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationClaim); + } + if unsupported_claim.trim() != unsupported_claim { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationClaimTooLong); + } + if !is_nfc(&unsupported_claim) { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim + .chars() + .any(disallowed_release_limitation_character) + || !unsupported_claim.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if buyer_consequence.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationConsequence); + } + if buyer_consequence.trim() != buyer_consequence { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationConsequenceTooLong); + } + if !is_nfc(&buyer_consequence) { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence + .chars() + .any(disallowed_release_limitation_character) + || !buyer_consequence.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + Ok(Self { + unsupported_claim, + buyer_consequence, + }) + } + + /// Return the exact unsupported or narrowed release claim. + #[must_use] + pub fn unsupported_claim(&self) -> &str { + &self.unsupported_claim + } + + /// Return the exact consequence exposed to buyers and operators. + #[must_use] + pub fn buyer_consequence(&self) -> &str { + &self.buyer_consequence + } +} + +fn disallowed_release_limitation_character(character: char) -> bool { + let code_point = character as u32; + character.is_control() + || matches!( + code_point, + 0x00ad + | 0x034f + | 0x061c + | 0x115f..=0x1160 + | 0x17b4..=0x17b5 + | 0x180b..=0x180f + | 0x200b..=0x200f + | 0x2028..=0x202e + | 0x2060..=0x206f + | 0x3164 + | 0xfe00..=0xfe0f + | 0xfeff + | 0xffa0 + | 0xfff0..=0xfff8 + | 0x1bca0..=0x1bca3 + | 0x1d173..=0x1d17a + | 0xe0000..=0xe0fff + ) +} + +/// Deterministic release decision produced from mandatory suite evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecision { + /// Every mandatory suite passed for the full declared support profile. + Accepted, + /// Every mandatory suite passed after buyer-visible limitations were declared. + AcceptedWithDeclaredLimitations, + /// At least one mandatory suite is known to have failed its threshold. + Rejected, + /// No known threshold failure exists, but mandatory evidence is incomplete. + Inconclusive, +} + +/// Fail-closed input error while constructing a release decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecisionError { + /// A declared limitation did not identify the unsupported release claim. + EmptyLimitationClaim, + /// A declared limitation claim exceeded the fixed UTF-8 byte budget. + LimitationClaimTooLong, + /// A declared limitation claim was not canonical NFC text or was presentation-unsafe. + InvalidLimitationClaim, + /// A declared limitation did not state the buyer-visible consequence. + EmptyLimitationConsequence, + /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. + LimitationConsequenceTooLong, + /// A limitation consequence was not canonical NFC text or was presentation-unsafe. + InvalidLimitationConsequence, + /// One release report supplied more buyer-visible limitations than the fixed resource budget. + TooManyDeclaredLimitations, + /// More than one limitation used the same unsupported claim identity. + DuplicateLimitationClaim, + /// The same suite appeared more than once instead of one authoritative result. + DuplicateSuite(BenchmarkSuite), +} + +impl fmt::Display for ReleaseDecisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyLimitationClaim => { + formatter.write_str("declared release limitation must name an unsupported claim") + } + Self::LimitationClaimTooLong => { + formatter.write_str("declared release limitation claim exceeds the byte budget") + } + Self::InvalidLimitationClaim => formatter.write_str( + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + Self::EmptyLimitationConsequence => formatter + .write_str("declared release limitation must state a buyer-visible consequence"), + Self::LimitationConsequenceTooLong => formatter + .write_str("declared release limitation consequence exceeds the byte budget"), + Self::InvalidLimitationConsequence => formatter.write_str( + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + Self::TooManyDeclaredLimitations => formatter + .write_str("benchmark release decision contains too many declared limitations"), + Self::DuplicateLimitationClaim => formatter + .write_str("benchmark release decision contains duplicate limitation claim"), + Self::DuplicateSuite(suite) => write!( + formatter, + "benchmark release evidence contains duplicate suite: {}", + suite.as_str() + ), + } + } +} + +impl std::error::Error for ReleaseDecisionError {} + +/// Release decision together with exact mandatory-suite evidence gaps and failures. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseDecisionReport { + decision: ReleaseDecision, + failed_suites: Vec, + inconclusive_suites: Vec, + missing_suites: Vec, + declared_limitations: Vec, +} + +impl ReleaseDecisionReport { + /// Return the deterministic release decision. + #[must_use] + pub const fn decision(&self) -> ReleaseDecision { + self.decision + } + + /// Return suites with a known mandatory-threshold failure. + #[must_use] + pub fn failed_suites(&self) -> &[BenchmarkSuite] { + &self.failed_suites + } + + /// Return suites whose supplied evidence was explicitly inconclusive. + #[must_use] + pub fn inconclusive_suites(&self) -> &[BenchmarkSuite] { + &self.inconclusive_suites + } + + /// Return mandatory suites for which no outcome was supplied. + #[must_use] + pub fn missing_suites(&self) -> &[BenchmarkSuite] { + &self.missing_suites + } + + /// Return the exact buyer-visible limitations retained with this decision. + #[must_use] + pub fn declared_limitations(&self) -> &[DeclaredLimitation] { + &self.declared_limitations + } +} + +/// Produce one deterministic release decision from mandatory suite outcomes. +/// +/// Duplicate suite evidence, duplicate buyer-visible limitation claim identities, +/// and excessive declared-limitation cardinality fail closed rather than selecting +/// or retaining ambiguous or attacker-controlled release metadata. A known +/// mandatory-threshold failure is always rejected, even when other suites are +/// missing or inconclusive; all such evidence gaps remain in the returned report. +/// Without a known failure, missing or inconclusive evidence is never promoted to +/// acceptance. Accepted-with-limitations requires at least one validated +/// [`DeclaredLimitation`], so the decision cannot be detached from the exact +/// narrowed claim and buyer-visible consequence. +pub fn decide_release( + results: I, + declared_limitations: &[DeclaredLimitation], +) -> Result +where + I: IntoIterator, +{ + if declared_limitations.len() > MAX_DECLARED_RELEASE_LIMITATIONS { + return Err(ReleaseDecisionError::TooManyDeclaredLimitations); + } + + let mut limitation_claims = std::collections::BTreeSet::new(); + for limitation in declared_limitations { + if !limitation_claims.insert(limitation.unsupported_claim()) { + return Err(ReleaseDecisionError::DuplicateLimitationClaim); + } + } + + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; + for (suite, outcome) in results { + let slot = &mut outcomes[suite.index()]; + if slot.is_some() { + return Err(ReleaseDecisionError::DuplicateSuite(suite)); + } + *slot = Some(outcome); + } + + let mut failed_suites = Vec::new(); + let mut inconclusive_suites = Vec::new(); + let mut missing_suites = Vec::new(); + for suite in BenchmarkSuite::ALL { + match outcomes[suite.index()] { + Some(BenchmarkSuiteOutcome::Passed) => {} + Some(BenchmarkSuiteOutcome::Failed) => failed_suites.push(suite), + Some(BenchmarkSuiteOutcome::Inconclusive) => inconclusive_suites.push(suite), + None => missing_suites.push(suite), + } + } + + let decision = if !failed_suites.is_empty() { + ReleaseDecision::Rejected + } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { + ReleaseDecision::Inconclusive + } else if declared_limitations.is_empty() { + ReleaseDecision::Accepted + } else { + ReleaseDecision::AcceptedWithDeclaredLimitations + }; + + Ok(ReleaseDecisionReport { + decision, + failed_suites, + inconclusive_suites, + missing_suites, + declared_limitations: declared_limitations.to_vec(), + }) +} diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs index 83d2e9b04..4ab4fe077 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -51,6 +51,8 @@ impl WebDriverBiDiWebSocketEndpoint { return Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden); } + // Plaintext BiDi is admitted only after exact loopback authority validation below. + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket let (secure, remainder) = if let Some(remainder) = value.strip_prefix("ws://") { (false, remainder) } else if let Some(remainder) = value.strip_prefix("wss://") { diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index 82507a244..f34c30e9b 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -2,7 +2,7 @@ use originweave_core::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, evaluate_extension_access, + ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { @@ -17,6 +17,13 @@ fn context(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("nonzero browsing context") } +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { let canonical = "abcdefghijklmnopabcdefghijklmnop"; @@ -43,10 +50,13 @@ fn extension_id_accepts_only_canonical_chromium_extension_ids() { fn extension_agent_access_requires_an_explicit_exact_grant() { let allowed_extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); let other_extension = extension_id("bcdefghijklmnopabcdefghijklmnopa"); + let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -54,6 +64,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -68,6 +80,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { other_extension, session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -79,6 +93,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(8), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -87,24 +103,55 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { ); let wrong_context = ExtensionAccessRequest::new( - allowed_extension, + allowed_extension.clone(), session(7), context(12), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( evaluate_extension_access(&wrong_context, Some(&grant)), ExtensionAccessDecision::DenyBrowsingContextMismatch ); + + let wrong_origin = ExtensionAccessRequest::new( + allowed_extension.clone(), + session(7), + context(11), + origin("https://other.example"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_origin, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); + + let wrong_port = ExtensionAccessRequest::new( + allowed_extension, + session(7), + context(11), + origin("https://app.example:8443"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_port, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); } #[test] fn chrome_permissions_never_imply_originweave_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), session(3), context(5), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -112,6 +159,8 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { id, session(3), context(5), + granted_origin, + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( @@ -123,10 +172,13 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { #[test] fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), session(13), context(17), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, @@ -137,10 +189,71 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, ] { - let request = ExtensionAccessRequest::new(id.clone(), session(13), context(17), capability); + let request = ExtensionAccessRequest::new( + id.clone(), + session(13), + context(17), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, + capability, + ); assert_eq!( evaluate_extension_access(&request, Some(&grant)), ExtensionAccessDecision::Allow ); } } + +#[test] +fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://billing.example"); + let expires_at_epoch_seconds = 1_700_000_100; + let grant = ExtensionAgentGrant::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + [ExtensionAgentCapability::ObserveCurrentContext], + ); + + let before_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds - 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&before_deadline, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let at_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&at_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); + + let after_deadline = ExtensionAccessRequest::new( + id, + session(19), + context(23), + granted_origin, + expires_at_epoch_seconds + 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&after_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); +} diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs new file mode 100644 index 000000000..80357ec63 --- /dev/null +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -0,0 +1,362 @@ +use std::error::Error; + +use originweave_core::mcp::{ + MAX_MCP_METHOD_NAME_BYTES, MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, ValidatedMcpToolCall, supported_mcp_tools, +}; +use originweave_core::{ActionKind, Capability, RiskClass}; + +fn validate(tool_name: &str) -> Result { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) +} + +#[test] +fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box> { + let cases = [ + ( + "originweave.observe", + ActionKind::Observe, + Capability::Observe, + RiskClass::R0, + ), + ( + "originweave.extract", + ActionKind::Extract, + Capability::Extract, + RiskClass::R0, + ), + ( + "originweave.navigate", + ActionKind::Navigate, + Capability::Navigate, + RiskClass::R1, + ), + ( + "originweave.download", + ActionKind::Download, + Capability::Download, + RiskClass::R1, + ), + ( + "originweave.draft", + ActionKind::Draft, + Capability::Draft, + RiskClass::R2, + ), + ( + "originweave.submit", + ActionKind::Submit, + Capability::Submit, + RiskClass::R3, + ), + ( + "originweave.upload", + ActionKind::Upload, + Capability::Upload, + RiskClass::R3, + ), + ( + "originweave.fill_secret", + ActionKind::FillSecret, + Capability::FillSecret, + RiskClass::R3, + ), + ( + "originweave.purchase", + ActionKind::Purchase, + Capability::Purchase, + RiskClass::R4, + ), + ( + "originweave.delete", + ActionKind::Delete, + Capability::Delete, + RiskClass::R4, + ), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + Capability::ManagePermission, + RiskClass::R4, + ), + ]; + + for (tool_name, expected_action, expected_capability, expected_risk) in cases { + let call = validate(tool_name)?; + assert_eq!(call.tool_name(), tool_name); + assert_eq!(call.action_kind(), expected_action); + assert_eq!( + call.action_kind().required_capability(), + expected_capability + ); + assert_eq!(call.action_kind().risk_class(), expected_risk); + } + Ok(()) +} + +#[test] +fn mcp_tool_catalog_is_deterministic_complete_and_action_unambiguous() -> Result<(), Box> +{ + let expected = [ + ("originweave.observe", ActionKind::Observe), + ("originweave.extract", ActionKind::Extract), + ("originweave.navigate", ActionKind::Navigate), + ("originweave.download", ActionKind::Download), + ("originweave.draft", ActionKind::Draft), + ("originweave.submit", ActionKind::Submit), + ("originweave.upload", ActionKind::Upload), + ("originweave.fill_secret", ActionKind::FillSecret), + ("originweave.purchase", ActionKind::Purchase), + ("originweave.delete", ActionKind::Delete), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + ), + ]; + let catalog = supported_mcp_tools(); + + assert_eq!(catalog.len(), expected.len()); + for (entry, (expected_name, expected_action)) in catalog.iter().zip(expected) { + assert_eq!(entry.tool_name(), expected_name); + assert_eq!(entry.action_kind(), expected_action); + assert_eq!( + entry.required_capability(), + expected_action.required_capability() + ); + assert_eq!(entry.risk_class(), expected_action.risk_class()); + + let call = validate(entry.tool_name())?; + assert_eq!(call.action_kind(), entry.action_kind()); + } + + for (index, entry) in catalog.iter().enumerate() { + for other in &catalog[index + 1..] { + assert_ne!(entry.tool_name(), other.tool_name()); + assert_ne!(entry.action_kind(), other.action_kind()); + } + } + assert!( + catalog + .iter() + .all(|entry| entry.action_kind() != ActionKind::LegalConsent) + ); + Ok(()) +} + +#[test] +fn mcp_route_rejects_protocol_header_body_and_method_drift() { + assert_eq!( + ValidatedMcpToolCall::new( + "2025-11-25", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "tools/list", + "originweave.observe", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.extract", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "resources/read", + "originweave.observe", + "resources/read", + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + +#[test] +fn mcp_route_bounds_each_untrusted_method_before_cross_field_comparison() { + let at_limit = "x".repeat(MAX_MCP_METHOD_NAME_BYTES); + let oversized_routing = "r".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "", + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &oversized_routing, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + &oversized_body, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "tools call", + "originweave.observe", + "tools call", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &at_limit, + "originweave.observe", + &at_limit, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + +#[test] +fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { + let at_limit = "x".repeat(MAX_MCP_TOOL_NAME_BYTES); + let oversized = "x".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + for tool_name in [ + "", + "originweave legal", + "originweave/observe", + "originweave.관찰", + &oversized, + ] { + assert_eq!( + validate(tool_name), + Err(McpToolBoundaryError::InvalidToolName) + ); + } + + assert_eq!(validate(&at_limit), Err(McpToolBoundaryError::UnknownTool)); + assert_eq!( + validate("originweave.legal_consent"), + Err(McpToolBoundaryError::UnknownTool) + ); + assert_eq!( + validate("third_party.arbitrary_javascript"), + Err(McpToolBoundaryError::UnknownTool) + ); +} + +#[test] +fn mcp_route_validates_each_untrusted_tool_name_before_cross_field_comparison() { + let oversized_routing = "r".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + &oversized_routing, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + &oversized_body, + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave/observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); +} + +#[test] +fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { + let cases = [ + ( + McpToolBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolBoundaryError::HeaderBodyMismatch, + "MCP routing headers do not match the request body", + ), + ( + McpToolBoundaryError::UnsupportedMethod, + "only MCP tools/call requests can enter the typed action boundary", + ), + ( + McpToolBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), + ( + McpToolBoundaryError::InvalidToolName, + "MCP tool name violates the bounded ASCII routing syntax", + ), + ( + McpToolBoundaryError::UnknownTool, + "MCP tool is not mapped to an OriginWeave typed action", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs new file mode 100644 index 000000000..9d3681673 --- /dev/null +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -0,0 +1,221 @@ +use std::error::Error; + +use originweave_core::mcp::{ + MAX_MCP_METHOD_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, + McpResultType, McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, + supported_mcp_tools, +}; + +#[test] +fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { + let page = mcp_tools_list_page(); + + assert_eq!(page.result_type(), McpResultType::Complete); + assert_eq!(page.tools(), supported_mcp_tools()); + assert_eq!(page.ttl_ms(), 0); + assert_eq!(page.cache_scope(), McpCacheScope::Private); + assert_eq!(page.next_cursor(), None); +} + +fn valid_tools_list_request( + cursor: Option<&str>, +) -> Result { + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + cursor, + ) +} + +#[test] +fn mcp_tools_list_request_requires_complete_request_metadata() { + assert_eq!( + valid_tools_list_request(None).map(|validated| validated.method()), + Ok(MCP_TOOLS_LIST_METHOD) + ); + + assert_eq!( + ValidatedMcpToolsListRequest::new( + None, + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionHeader) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + None, + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionMetadata) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some("2025-11-25"), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + false, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingClientCapabilities) + ); +} + +#[test] +fn mcp_tools_list_bounds_protocol_metadata_before_cross_field_comparison() { + let oversized_protocol_version = format!("{MCP_PROTOCOL_VERSION}0"); + + for (header, metadata) in [ + (oversized_protocol_version.as_str(), MCP_PROTOCOL_VERSION), + (MCP_PROTOCOL_VERSION, oversized_protocol_version.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(header), + Some(metadata), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + } +} + +#[test] +fn mcp_tools_list_validates_each_method_before_cross_field_comparison() { + let oversized_method = "a".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + for (routing_method, body_method) in [ + ("tools list", MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, "tools list"), + (oversized_method.as_str(), MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, oversized_method.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + routing_method, + body_method, + None, + ), + Err(McpToolsListBoundaryError::InvalidMethod) + ); + } +} + +#[test] +fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + "tools/call", + None, + ), + Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + "resources/list", + "resources/list", + None, + ), + Err(McpToolsListBoundaryError::UnsupportedMethod) + ); + + for cursor in ["cursor-1", ""] { + assert_eq!( + valid_tools_list_request(Some(cursor)), + Err(McpToolsListBoundaryError::UnsupportedCursor) + ); + } +} + +#[test] +fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { + let cases = [ + ( + McpToolsListBoundaryError::MissingProtocolVersionHeader, + "MCP protocol version header is required", + ), + ( + McpToolsListBoundaryError::MissingProtocolVersionMetadata, + "MCP request metadata protocol version is required", + ), + ( + McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch, + "MCP protocol version header does not match request metadata", + ), + ( + McpToolsListBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolsListBoundaryError::MissingClientCapabilities, + "MCP request metadata client capabilities are required", + ), + ( + McpToolsListBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), + ( + McpToolsListBoundaryError::MethodHeaderBodyMismatch, + "MCP method header does not match the request body", + ), + ( + McpToolsListBoundaryError::UnsupportedMethod, + "only MCP tools/list requests can enter the discovery boundary", + ), + ( + McpToolsListBoundaryError::UnsupportedCursor, + "MCP tools/list cursor was not issued by this fixed catalog", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/origin_port_syntax.rs b/crates/originweave-core/tests/origin_port_syntax.rs new file mode 100644 index 000000000..ce58e523e --- /dev/null +++ b/crates/originweave-core/tests/origin_port_syntax.rs @@ -0,0 +1,18 @@ +use originweave_core::{Origin, OriginError}; + +#[test] +fn origin_rejects_non_digit_port_prefixes() { + for input in [ + "https://example.com:+443", + "https://example.com:+8443", + "http://localhost:+80", + "http://127.0.0.1:+8080", + "https://[2001:db8::1]:+443", + ] { + assert_eq!( + Origin::parse(input), + Err(OriginError::InvalidPort), + "input={input}" + ); + } +} diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs new file mode 100644 index 000000000..3e37fab18 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -0,0 +1,397 @@ +use originweave_core::release_acceptance::{ + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, + ReleaseDecision, ReleaseDecisionError, decide_release, +}; + +fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { + BenchmarkSuite::ALL + .into_iter() + .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) + .collect() +} + +fn declared_limitation() -> Result { + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is not included in the declared release support profile.", + ) +} + +#[test] +fn generic_constructor_input_shapes_cover_success_paths_in_this_test_crate() { + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn complete_passing_evidence_is_accepted_without_declared_limitations() +-> Result<(), ReleaseDecisionError> { + let report = decide_release(passing_results(), &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Accepted); + assert!(report.failed_suites().is_empty()); + assert!(report.inconclusive_suites().is_empty()); + assert!(report.missing_suites().is_empty()); + assert!(report.declared_limitations().is_empty()); + Ok(()) +} + +#[test] +fn complete_passing_evidence_preserves_declared_limitation_details() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + let report = decide_release(passing_results(), std::slice::from_ref(&limitation))?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!(report.declared_limitations(), &[limitation]); + Ok(()) +} + +#[test] +fn limitation_requires_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new( + " ", + "A buyer-visible consequence must not stand without the narrowed claim.", + ), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); +} + +#[test] +fn limitation_requires_a_buyer_visible_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "\t\n"), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_control_characters_in_release_metadata() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64\nforged_release_claim", + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is unsupported.\rforged_release_consequence" + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_ambiguous_unicode_formatting_characters() { + for character in [ + '\u{00ad}', '\u{061c}', '\u{180e}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', + '\u{2060}', '\u{2066}', '\u{206f}', '\u{feff}', + ] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence") + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + } +} + +#[test] +fn limitation_preserves_unambiguous_international_buyer_text() -> Result<(), ReleaseDecisionError> { + let limitation = DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + )?; + + assert_eq!(limitation.unsupported_claim(), "한국어_운영환경"); + assert_eq!( + limitation.buyer_consequence(), + "이 운영환경은 현재 지원 범위에 포함되지 않습니다." + ); + Ok(()) +} + +#[test] +fn limitation_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::EmptyLimitationClaim, + "declared release limitation must name an unsupported claim", + ), + ( + ReleaseDecisionError::InvalidLimitationClaim, + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::EmptyLimitationConsequence, + "declared release limitation must state a buyer-visible consequence", + ), + ( + ReleaseDecisionError::InvalidLimitationConsequence, + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::DuplicateLimitationClaim, + "benchmark release decision contains duplicate limitation claim", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn limitation_exposes_the_exact_narrowed_claim_and_consequence() -> Result<(), ReleaseDecisionError> +{ + let limitation = declared_limitation()?; + + assert_eq!(limitation.unsupported_claim(), "linux_arm64"); + assert_eq!( + limitation.buyer_consequence(), + "Linux ARM64 is not included in the declared release support profile." + ); + Ok(()) +} + +#[test] +fn every_mandatory_suite_is_required_for_acceptance() -> Result<(), ReleaseDecisionError> { + for omitted_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .filter(|(suite, _)| *suite != omitted_suite) + .collect::>(); + + let report = decide_release(evidence, &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.missing_suites(), &[omitted_suite]); + assert!(report.failed_suites().is_empty()); + } + Ok(()) +} + +#[test] +fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() +-> Result<(), ReleaseDecisionError> { + for inconclusive_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == inconclusive_suite { + (suite, BenchmarkSuiteOutcome::Inconclusive) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() +-> Result<(), ReleaseDecisionError> { + for failed_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == failed_suite { + (suite, BenchmarkSuiteOutcome::Failed) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!(report.failed_suites(), &[failed_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn known_failure_remains_rejected_when_other_evidence_is_incomplete() +-> Result<(), ReleaseDecisionError> { + let report = decide_release( + vec![ + ( + BenchmarkSuite::ControlledDeterministic, + BenchmarkSuiteOutcome::Failed, + ), + ( + BenchmarkSuite::WebCompatibility, + BenchmarkSuiteOutcome::Inconclusive, + ), + ], + &[], + )?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!( + report.failed_suites(), + &[BenchmarkSuite::ControlledDeterministic] + ); + assert_eq!( + report.inconclusive_suites(), + &[BenchmarkSuite::WebCompatibility] + ); + assert_eq!( + report.missing_suites(), + &[ + BenchmarkSuite::SecurityAdversarial, + BenchmarkSuite::ReliabilityRecovery, + BenchmarkSuite::EnterpriseOperability, + ] + ); + Ok(()) +} + +#[test] +fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { + for duplicate_suite in BenchmarkSuite::ALL { + let expected_error = ReleaseDecisionError::DuplicateSuite(duplicate_suite); + assert_eq!( + decide_release( + vec![ + (duplicate_suite, BenchmarkSuiteOutcome::Passed), + (duplicate_suite, BenchmarkSuiteOutcome::Failed), + ], + &[], + ), + Err(expected_error) + ); + + assert_eq!( + expected_error.to_string(), + format!( + "benchmark release evidence contains duplicate suite: {}", + duplicate_suite.as_str() + ) + ); + let standard_error: &dyn std::error::Error = &expected_error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn duplicate_suite_evidence_in_vector_input_also_fails_closed() { + let duplicate_suite = BenchmarkSuite::ControlledDeterministic; + let mut evidence = passing_results(); + evidence.push((duplicate_suite, BenchmarkSuiteOutcome::Failed)); + + assert_eq!( + decide_release(evidence, &[]), + Err(ReleaseDecisionError::DuplicateSuite(duplicate_suite)) + ); +} + +#[test] +fn decision_is_independent_of_evidence_input_order() { + let mut reversed = passing_results(); + reversed.reverse(); + + assert_eq!( + decide_release(reversed, &[]), + decide_release(passing_results(), &[]) + ); +} + +#[test] +fn conflicting_consequences_for_one_limitation_claim_fail_closed() +-> Result<(), ReleaseDecisionError> { + let first = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + )?; + let conflicting = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is supported only for evaluation deployments.", + )?; + + assert_eq!( + decide_release(passing_results(), &[first, conflicting]), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn duplicate_limitation_claim_fails_closed_even_when_consequence_matches() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + + assert_eq!( + decide_release(passing_results(), &[limitation.clone(), limitation],), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn release_report_bounds_declared_limitation_count_before_cloning() +-> Result<(), ReleaseDecisionError> { + let maximum = (0..MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + let report = decide_release(passing_results(), &maximum)?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!( + report.declared_limitations().len(), + MAX_DECLARED_RELEASE_LIMITATIONS + ); + + let too_many = (0..=MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + assert_eq!( + decide_release(passing_results(), &too_many), + Err(ReleaseDecisionError::TooManyDeclaredLimitations) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs new file mode 100644 index 000000000..2d7840af3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -0,0 +1,116 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn limitation_accepts_canonical_boundary_text() { + let limitation = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + + assert_eq!( + limitation + .as_ref() + .map(|value| (value.unsupported_claim(), value.buyer_consequence())), + Ok(( + "linux_arm64", + "Linux ARM64 is excluded from the support profile." + )) + ); +} + +#[test] +fn limitation_rejects_empty_fields_for_the_canonical_string_input_shape() { + assert_eq!( + DeclaredLimitation::new("", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); +} + +#[test] +fn limitation_rejects_surrounding_whitespace_that_changes_claim_identity() { + for unsupported_claim in [" linux_arm64", "linux_arm64 ", "\tlinux_arm64"] { + assert_eq!( + DeclaredLimitation::new( + unsupported_claim, + "Linux ARM64 is excluded from the support profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "surrounding whitespace must not create a second spelling for one claim identity: {unsupported_claim:?}", + ); + } +} + +#[test] +fn limitation_rejects_surrounding_whitespace_in_buyer_consequence() { + for buyer_consequence in [ + " Linux ARM64 is excluded from the support profile.", + "Linux ARM64 is excluded from the support profile. ", + "Linux ARM64 is excluded from the support profile.\t", + ] { + assert_eq!( + DeclaredLimitation::new("linux_arm64", buyer_consequence), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequence must have one canonical boundary spelling: {buyer_consequence:?}", + ); + } +} + +#[test] +fn limitation_rejects_non_nfc_claim_identity() { + let nfc_claim = "caf\u{e9}"; + let canonically_equivalent_nfd_claim = "cafe\u{301}"; + + assert!( + DeclaredLimitation::new( + nfc_claim, + "This normalized claim remains a supported buyer-visible spelling.", + ) + .is_ok(), + "NFC international text must remain admissible", + ); + assert_eq!( + DeclaredLimitation::new( + canonically_equivalent_nfd_claim, + "This decomposed spelling must not create a second claim identity.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "canonically equivalent NFD text must not bypass limitation identity", + ); +} + +#[test] +fn limitation_rejects_non_nfc_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequences must use one canonical Unicode spelling", + ); +} + +#[test] +fn invalid_canonical_text_errors_describe_all_rejected_causes() { + let claim_result = DeclaredLimitation::new( + " linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + assert_eq!( + claim_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation claim is not canonical or contains an unsafe presentation character".to_owned()) + ); + + let consequence_result = DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ); + assert_eq!( + consequence_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation consequence is not canonical or contains an unsafe presentation character".to_owned()) + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs new file mode 100644 index 000000000..0dfb20ba3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs @@ -0,0 +1,46 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn punctuation_only_limitation_claim_does_not_name_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new("---", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); +} + +#[test] +fn punctuation_only_limitation_consequence_does_not_state_a_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "..."), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn meaningful_text_may_begin_with_allowed_punctuation() { + assert!( + DeclaredLimitation::new( + "-linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ) + .is_ok() + ); + assert!( + DeclaredLimitation::new( + "linux_arm64", + "... Linux ARM64 remains outside the support profile.", + ) + .is_ok() + ); +} + +#[test] +fn international_alphanumeric_limitation_text_remains_admissible() { + assert!( + DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + ) + .is_ok() + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs new file mode 100644 index 000000000..fd45e0e6d --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -0,0 +1,98 @@ +use originweave_core::release_acceptance::{ + DeclaredLimitation, MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecisionError, +}; + +#[test] +fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { + let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let limitation = DeclaredLimitation::new(maximum_claim.as_str(), maximum_consequence.as_str())?; + + assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); + assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); + + let oversized_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new(oversized_claim.as_str(), "bounded buyer consequence"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); + + let oversized_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new("bounded_claim", oversized_consequence.as_str()), + Err(ReleaseDecisionError::LimitationConsequenceTooLong) + ); + Ok(()) +} + +#[test] +fn borrowed_limitation_text_covers_every_validation_exit() { + assert_eq!( + DeclaredLimitation::new("", "bounded buyer consequence"), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new(" bounded_claim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("cafe\u{301}", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "bounded buyer consequence "), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "cafe\u{301} buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "forged\nconsequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_byte_budget_applies_to_international_text() { + let korean_character = "가"; + let repeated = + korean_character.repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1); + assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); + assert_eq!( + DeclaredLimitation::new(repeated.as_str(), "지원 범위를 설명하는 구매자 안내"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); +} + +#[test] +fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::LimitationClaimTooLong, + "declared release limitation claim exceeds the byte budget", + ), + ( + ReleaseDecisionError::LimitationConsequenceTooLong, + "declared release limitation consequence exceeds the byte budget", + ), + ( + ReleaseDecisionError::TooManyDeclaredLimitations, + "benchmark release decision contains too many declared limitations", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs new file mode 100644 index 000000000..eccd90e89 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -0,0 +1,121 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +const UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT: usize = 4_174; + +#[test] +fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { + assert_eq!( + DeclaredLimitation::new(String::new(), "Linux ARM64 is unsupported."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", String::new()), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { + // Unicode 17.0.0 DerivedCoreProperties.txt (2025-07-30), + // Default_Ignorable_Code_Point. The reviewed ranges contain exactly 4,174 code points. + let ranges = [ + (0x00ad_u32, 0x00ad_u32), + (0x034f, 0x034f), + (0x061c, 0x061c), + (0x115f, 0x1160), + (0x17b4, 0x17b5), + (0x180b, 0x180f), + (0x200b, 0x200f), + (0x202a, 0x202e), + (0x2060, 0x206f), + (0x3164, 0x3164), + (0xfe00, 0xfe0f), + (0xfeff, 0xfeff), + (0xffa0, 0xffa0), + (0xfff0, 0xfff8), + (0x1bca0, 0x1bca3), + (0x1d173, 0x1d17a), + (0xe0000, 0xe0fff), + ]; + let mut tested_code_points = 0_usize; + + for (start, end) in ranges { + for code_point in start..=end { + let character = char::from_u32(code_point) + .ok_or("reviewed Unicode 17 default-ignorable range must contain scalar values")?; + tested_code_points += 1; + + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "U+{code_point:04X} must be rejected in the unsupported claim", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "U+{code_point:04X} must be rejected in the buyer consequence", + ); + } + } + + assert_eq!( + tested_code_points, UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT, + "reviewed Unicode 17 Default_Ignorable_Code_Point ranges must match the authoritative cardinality", + ); + Ok(()) +} + +#[test] +fn limitation_rejects_line_and_paragraph_separators_beyond_default_ignorable_set() { + for (name, separator) in [("U+2028", '\u{2028}'), ("U+2029", '\u{2029}')] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{separator}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "{name} must be rejected in the unsupported claim to prevent line-forging ambiguity", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{separator}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "{name} must be rejected in the buyer consequence to prevent line-forging ambiguity", + ); + } +} + +#[test] +fn limitation_does_not_blanket_reject_unicode_17_whitespace() -> Result<(), ReleaseDecisionError> { + let medium_mathematical_space = '\u{205f}'; + let ideographic_space = '\u{3000}'; + + let limitation = DeclaredLimitation::new( + format!("east{ideographic_space}asia"), + format!("Support is limited{medium_mathematical_space}to the declared profile."), + )?; + + assert_eq!( + limitation.unsupported_claim(), + format!("east{ideographic_space}asia") + ); + assert_eq!( + limitation.buyer_consequence(), + format!("Support is limited{medium_mathematical_space}to the declared profile.") + ); + Ok(()) +} diff --git a/crates/originweave-destination/src/lib.rs b/crates/originweave-destination/src/lib.rs index 5fdf2d363..774ba9ee9 100644 --- a/crates/originweave-destination/src/lib.rs +++ b/crates/originweave-destination/src/lib.rs @@ -24,6 +24,7 @@ pub use redirect::{ RedirectTargetDigestError, }; pub use resolution::{ - ConnectionEvidence, DestinationError, DestinationPolicy, MAX_RESOLUTION_ADDRESS_COUNT, + ConnectionEvidence, DestinationError, DestinationPolicy, FreshConnectionEvidence, + FreshResolutionSnapshot, MAX_RESOLUTION_ADDRESS_COUNT, MAX_RESOLUTION_VALIDITY, ResolutionSnapshot, }; diff --git a/crates/originweave-destination/src/proxy.rs b/crates/originweave-destination/src/proxy.rs index ef64289fa..4695dc3aa 100644 --- a/crates/originweave-destination/src/proxy.rs +++ b/crates/originweave-destination/src/proxy.rs @@ -446,6 +446,9 @@ fn explicit_port(authority: &str) -> Result, ProxyServerError> { port }; + if !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ProxyServerError::InvalidIdentifier); + } let port = port_text .parse::() .map_err(|_error| ProxyServerError::InvalidIdentifier)?; diff --git a/crates/originweave-destination/src/resolution.rs b/crates/originweave-destination/src/resolution.rs index f55d1722b..45620e6cd 100644 --- a/crates/originweave-destination/src/resolution.rs +++ b/crates/originweave-destination/src/resolution.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use std::fmt; use std::net::IpAddr; +use std::time::Duration; use originweave_core::Origin; @@ -9,6 +10,13 @@ use crate::{AddressClass, ClassifiedAddress, classify_address}; /// The largest resolver answer accepted by one resolution snapshot. pub const MAX_RESOLUTION_ADDRESS_COUNT: usize = 256; +/// The largest freshness interval accepted for one resolution approval. +/// +/// This is an OriginWeave product safety budget, not a DNS protocol validity +/// rule. Callers may choose any smaller non-zero interval appropriate to their +/// resolver and network adapter. +pub const MAX_RESOLUTION_VALIDITY: Duration = Duration::from_secs(30); + /// A fail-closed allow-list of destination address classes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DestinationPolicy { @@ -103,6 +111,34 @@ pub enum DestinationError { /// The newly introduced canonical address. address: IpAddr, }, + /// A freshness interval was zero or exceeded [`MAX_RESOLUTION_VALIDITY`]. + InvalidResolutionValidity { + /// The rejected freshness interval. + validity: Duration, + /// The largest accepted freshness interval. + maximum_validity: Duration, + }, + /// Adding the freshness interval to the approval time overflowed. + ResolutionValidityOverflow { + /// The trusted monotonic time at which the answer was approved. + approved_at: Duration, + /// The requested freshness interval. + validity: Duration, + }, + /// A caller supplied a monotonic time earlier than the recorded approval. + ResolutionUseBeforeApproval { + /// The recorded approval time. + approved_at: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, + /// A bounded resolution approval reached its exclusive validity deadline. + ResolutionApprovalExpired { + /// The exclusive upper bound of the approval interval. + valid_until: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, } impl fmt::Display for DestinationError { @@ -142,6 +178,34 @@ impl fmt::Display for DestinationError { formatter, "refreshed DNS answer introduced unapproved address {address}", ), + Self::InvalidResolutionValidity { + validity, + maximum_validity, + } => write!( + formatter, + "resolution validity {validity:?} is outside 1ns..={maximum_validity:?}", + ), + Self::ResolutionValidityOverflow { + approved_at, + validity, + } => write!( + formatter, + "resolution validity {validity:?} overflows approval time {approved_at:?}", + ), + Self::ResolutionUseBeforeApproval { + approved_at, + current_time, + } => write!( + formatter, + "resolution use time {current_time:?} precedes approval time {approved_at:?}", + ), + Self::ResolutionApprovalExpired { + valid_until, + current_time, + } => write!( + formatter, + "resolution approval expired at {valid_until:?}; current time is {current_time:?}", + ), } } } @@ -254,6 +318,143 @@ impl ResolutionSnapshot { } } +/// A resolution snapshot bound to one explicit trusted monotonic validity window. +/// +/// The time values are opaque durations from one caller-owned monotonic clock +/// domain. This type never reads a wall clock itself. Constructing a new fresh +/// snapshot always reruns the same destination validation used by +/// [`ResolutionSnapshot`], so callers cannot renew authority without presenting +/// another policy-valid answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshResolutionSnapshot { + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + valid_until: Duration, +} + +impl FreshResolutionSnapshot { + /// Validate addresses and bind the resulting snapshot to a bounded lifetime. + pub fn approve( + origin: Origin, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + approved_at: Duration, + validity: Duration, + ) -> Result { + let snapshot = ResolutionSnapshot::approve(origin, addresses, policy)?; + Self::from_snapshot(snapshot, approved_at, validity) + } + + fn from_snapshot( + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + ) -> Result { + if validity.is_zero() || validity > MAX_RESOLUTION_VALIDITY { + return Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }); + } + let Some(valid_until) = approved_at.checked_add(validity) else { + return Err(DestinationError::ResolutionValidityOverflow { + approved_at, + validity, + }); + }; + Ok(Self { + snapshot, + approved_at, + validity, + valid_until, + }) + } + + /// Return the logical origin whose DNS answer was approved. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.snapshot.origin() + } + + /// Return the canonical addresses pinned for this fresh snapshot. + #[must_use] + pub const fn addresses(&self) -> &BTreeSet { + self.snapshot.addresses() + } + + /// Return the trusted monotonic approval time. + #[must_use] + pub const fn approved_at(&self) -> Duration { + self.approved_at + } + + /// Return the configured non-zero validity budget. + #[must_use] + pub const fn validity(&self) -> Duration { + self.validity + } + + /// Return the exclusive upper bound of the approval interval. + #[must_use] + pub const fn valid_until(&self) -> Duration { + self.valid_until + } + + /// Authorize one pinned address only while the freshness window is valid. + pub fn authorize_connection( + &self, + address: IpAddr, + current_time: Duration, + ) -> Result { + self.validate_current_time(current_time)?; + let connection = self.snapshot.authorize_connection(address)?; + Ok(FreshConnectionEvidence { + connection, + resolution_approved_at: self.approved_at, + resolution_valid_until: self.valid_until, + authorized_at: current_time, + }) + } + + /// Revalidate a fresh answer and renew the same bounded validity budget. + /// + /// `revalidated_at` must come from the same monotonic clock domain and may + /// not precede this snapshot's approval time. Expansion of the pinned set + /// remains fail-closed under [`ResolutionSnapshot::revalidate`]. + pub fn revalidate( + &self, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + revalidated_at: Duration, + ) -> Result { + if revalidated_at < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time: revalidated_at, + }); + } + let snapshot = self.snapshot.revalidate(addresses, policy)?; + Self::from_snapshot(snapshot, revalidated_at, self.validity) + } + + fn validate_current_time(&self, current_time: Duration) -> Result<(), DestinationError> { + if current_time < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time, + }); + } + if current_time >= self.valid_until { + return Err(DestinationError::ResolutionApprovalExpired { + valid_until: self.valid_until, + current_time, + }); + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OriginHostConstraint { Domain, @@ -344,3 +545,38 @@ impl ConnectionEvidence { self.address_class } } + +/// Credential-free evidence that a pinned connection address was used while fresh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshConnectionEvidence { + connection: ConnectionEvidence, + resolution_approved_at: Duration, + resolution_valid_until: Duration, + authorized_at: Duration, +} + +impl FreshConnectionEvidence { + /// Return the underlying canonical destination/connection evidence. + #[must_use] + pub const fn connection_evidence(&self) -> &ConnectionEvidence { + &self.connection + } + + /// Return the trusted monotonic time at which the answer was approved. + #[must_use] + pub const fn resolution_approved_at(&self) -> Duration { + self.resolution_approved_at + } + + /// Return the exclusive upper bound of the resolution approval interval. + #[must_use] + pub const fn resolution_valid_until(&self) -> Duration { + self.resolution_valid_until + } + + /// Return the trusted monotonic time used for this authorization decision. + #[must_use] + pub const fn authorized_at(&self) -> Duration { + self.authorized_at + } +} diff --git a/crates/originweave-destination/tests/proxy_port_syntax.rs b/crates/originweave-destination/tests/proxy_port_syntax.rs new file mode 100644 index 000000000..9038c14ed --- /dev/null +++ b/crates/originweave-destination/tests/proxy_port_syntax.rs @@ -0,0 +1,29 @@ +use originweave_destination::{ProxyServer, ProxyServerError}; + +#[test] +fn proxy_server_rejects_non_digit_port_prefixes() { + for input in [ + "proxy.example:+8080", + "http://proxy.example:+8080", + "https://proxy.example:+8443", + "socks5://proxy.example:+1080", + "https://[2001:db8::1]:+8443", + ] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} + +#[test] +fn proxy_server_rejects_decimal_ports_outside_u16_range() { + for input in ["proxy.example:65536", "https://[2001:db8::1]:65536"] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs new file mode 100644 index 000000000..2df264563 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -0,0 +1,235 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{ + AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, + MAX_RESOLUTION_VALIDITY, +}; + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { + let approved_at = Duration::from_secs(100); + let validity = Duration::from_secs(5); + let target = origin("https://example.com"); + let approved = ipv4(8, 8, 8, 8); + let snapshot = FreshResolutionSnapshot::approve( + target.clone(), + [approved], + &DestinationPolicy::public_web(), + approved_at, + validity, + ) + .expect("bounded fresh resolution"); + + assert_eq!(snapshot.origin(), &target); + assert_eq!(snapshot.approved_at(), approved_at); + assert_eq!(snapshot.validity(), validity); + assert_eq!(snapshot.valid_until(), Duration::from_secs(105)); + + let evidence = snapshot + .authorize_connection(approved, approved_at) + .expect("authority begins at approval time"); + let connection = evidence.connection_evidence(); + assert_eq!(connection.origin(), &target); + assert_eq!(connection.requested_address(), approved); + assert_eq!(connection.canonical_address(), approved); + assert_eq!(connection.address_class(), AddressClass::Public); + assert_eq!(evidence.resolution_approved_at(), approved_at); + assert_eq!(evidence.resolution_valid_until(), Duration::from_secs(105)); + assert_eq!(evidence.authorized_at(), approved_at); + + snapshot + .authorize_connection(approved, Duration::from_secs(104)) + .expect("authority remains valid before the exclusive deadline"); + + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(99)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at, + current_time: Duration::from_secs(99), + }) + ); + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(105)), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(105), + current_time: Duration::from_secs(105), + }) + ); + assert_eq!( + snapshot.authorize_connection(ipv4(9, 9, 9, 9), approved_at), + Err(DestinationError::UnapprovedConnectionAddress { + address: ipv4(9, 9, 9, 9), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_invalid_or_overflowing_validity() { + let target = origin("https://example.com"); + let address = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + + for validity in [ + Duration::ZERO, + MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1), + ] { + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [address], + &policy, + Duration::from_secs(1), + validity, + ), + Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }) + ); + } + + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [address], + &policy, + Duration::MAX, + Duration::from_nanos(1), + ), + Err(DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_denied_addresses_before_granting_time_authority() { + let target = origin("https://example.com"); + let denied = ipv4(127, 0, 0, 1); + let public = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + let expected = Err(DestinationError::AddressClassDenied { + address: denied, + address_class: AddressClass::Loopback, + }); + + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [denied], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected.clone() + ); + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [denied, public], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected + ); +} + +#[test] +fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin("https://example.com"), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial fresh resolution"); + + let refreshed = snapshot + .revalidate([second], &policy, Duration::from_secs(13)) + .expect("a fresh non-expanding answer renews the bounded window"); + assert_eq!( + refreshed.addresses(), + &std::collections::BTreeSet::from([second]) + ); + assert_eq!(refreshed.approved_at(), Duration::from_secs(13)); + assert_eq!(refreshed.validity(), Duration::from_secs(4)); + assert_eq!(refreshed.valid_until(), Duration::from_secs(17)); + refreshed + .authorize_connection(second, Duration::from_secs(16)) + .expect("refreshed authority is usable before its new deadline"); + + assert_eq!( + snapshot.revalidate([second], &policy, Duration::from_secs(9)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }) + ); + assert_eq!( + snapshot.revalidate([unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); + assert_eq!( + snapshot.revalidate([first, unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} + +#[test] +fn freshness_errors_have_deterministic_bounded_messages() { + let invalid = DestinationError::InvalidResolutionValidity { + validity: Duration::ZERO, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }; + assert_eq!( + invalid.to_string(), + "resolution validity 0ns is outside 1ns..=30s" + ); + + let overflow = DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }; + assert!(overflow.to_string().contains("overflows approval time")); + + let before = DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }; + assert_eq!( + before.to_string(), + "resolution use time 9s precedes approval time 10s" + ); + + let expired = DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(15), + current_time: Duration::from_secs(15), + }; + assert_eq!( + expired.to_string(), + "resolution approval expired at 15s; current time is 15s" + ); +} diff --git a/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs new file mode 100644 index 000000000..3c8443554 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs @@ -0,0 +1,78 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{DestinationError, DestinationPolicy, FreshResolutionSnapshot}; + +fn origin() -> Origin { + Origin::parse("https://example.com").expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn post_expiry_revalidation_establishes_new_authority_without_reviving_the_old_snapshot() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + let expiry = Duration::from_secs(14); + assert_eq!( + snapshot.authorize_connection(first, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); + + let refreshed = snapshot + .revalidate([second], &policy, expiry) + .expect("fresh non-expanding validation may establish a new bounded snapshot"); + assert_eq!(refreshed.approved_at(), expiry); + assert_eq!(refreshed.valid_until(), Duration::from_secs(18)); + refreshed + .authorize_connection(second, expiry) + .expect("the newly validated snapshot has independent current authority"); + + assert_eq!( + snapshot.authorize_connection(second, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); +} + +#[test] +fn post_expiry_revalidation_still_rejects_address_set_expansion() { + let approved = ipv4(8, 8, 8, 8); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [approved], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + assert_eq!( + snapshot.revalidate([approved, unexpected], &policy, Duration::from_secs(14)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs new file mode 100644 index 000000000..14a86a24c --- /dev/null +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -0,0 +1,297 @@ +//! Versioned schema contracts for typed evidence extraction. +//! +//! These value objects describe what may be extracted and which reviewed +//! evidence channels may support each field. They do not read browser data, +//! disclose protected values, persist artifacts, execute models, or grant any +//! browser, network, secret, approval, or storage authority. + +use std::{collections::BTreeSet, fmt}; + +/// Maximum encoded byte length for an extraction schema or field identifier. +pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; +/// Maximum number of fields admitted by one extraction schema. +pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256; + +/// The typed value contract for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionValueType { + /// Bounded textual data. + Text, + /// A whole-number value. + Integer, + /// A decimal numeric value. + Decimal, + /// A boolean value. + Boolean, + /// A timestamp value whose concrete normalization is defined by the schema version. + Timestamp, +} + +/// The number of values admitted for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionCardinality { + /// Exactly one value is admitted. + One, + /// Zero or one value is admitted. + ZeroOrOne, + /// A bounded collection may be admitted by a later extraction runtime. + Many, +} + +/// A reviewed evidence channel that may support an extracted value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionSourceChannel { + /// A semantic browser node with an independently validated identity. + SemanticNode, + /// Embedded structured metadata such as JSON-LD, RDFa, or Microdata. + StructuredData, + /// A bounded table-cell observation. + TableCell, + /// A bounded network response whose origin and response identity are independently verified. + NetworkResponse, + /// A separately approved model interpretation backed by explicit evidence identifiers. + ModelInterpretation, +} + +/// A deterministic normalization rule declared for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionNormalizationRule { + /// Preserve the typed source value without text normalization. + Verbatim, + /// Trim surrounding whitespace from a textual value. + TrimTextWhitespace, + /// Normalize a timestamp into an RFC 3339 UTC representation. + Rfc3339Utc, +} + +/// A validation failure while constructing an extraction schema contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtractionSchemaError { + /// A schema or field identifier was empty or outside the accepted identifier grammar. + InvalidIdentifier, + /// An identifier or field collection exceeded its bounded limit. + LimitExceeded, + /// A field's required flag contradicted its declared cardinality. + InvalidCardinalityRequirement, + /// A field did not declare any reviewed source channel. + MissingSourceChannel, + /// A field declared the same source channel more than once. + DuplicateSourceChannel, + /// The declared normalization rule was incompatible with the field value type. + InvalidNormalizationRule, + /// A schema did not contain any field definitions. + MissingField, + /// A schema declared the same field identifier more than once. + DuplicateField, +} + +impl fmt::Display for ExtractionSchemaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidIdentifier => "invalid extraction schema or field identifier", + Self::LimitExceeded => "extraction schema limit exceeded", + Self::InvalidCardinalityRequirement => { + "extraction field required flag is incompatible with the declared cardinality" + } + Self::MissingSourceChannel => "extraction field requires at least one source channel", + Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", + Self::InvalidNormalizationRule => { + "extraction normalization rule is incompatible with the field value type" + } + Self::MissingField => "extraction schema requires at least one field", + Self::DuplicateField => "extraction schema contains a duplicate field identifier", + }) + } +} + +impl std::error::Error for ExtractionSchemaError {} + +/// One typed field declared by a versioned extraction schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionField { + identifier: String, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: Vec, +} + +impl ExtractionField { + /// Validate and construct one extraction field contract with verbatim normalization. + pub fn new( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + Self::new_with_normalization( + identifier, + value_type, + cardinality, + required, + ExtractionNormalizationRule::Verbatim, + source_channels, + ) + } + + /// Validate and construct one extraction field with an explicit normalization rule. + pub fn new_with_normalization( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + validate_identifier(identifier)?; + + let cardinality_requirement_is_compatible = match cardinality { + ExtractionCardinality::One => required, + ExtractionCardinality::ZeroOrOne => !required, + ExtractionCardinality::Many => true, + }; + if !cardinality_requirement_is_compatible { + return Err(ExtractionSchemaError::InvalidCardinalityRequirement); + } + + if source_channels.is_empty() { + return Err(ExtractionSchemaError::MissingSourceChannel); + } + + let normalization_is_compatible = match normalization_rule { + ExtractionNormalizationRule::Verbatim => true, + ExtractionNormalizationRule::TrimTextWhitespace => { + value_type == ExtractionValueType::Text + } + ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp, + }; + if !normalization_is_compatible { + return Err(ExtractionSchemaError::InvalidNormalizationRule); + } + + let mut seen_channels = BTreeSet::new(); + for source_channel in source_channels { + if !seen_channels.insert(*source_channel) { + return Err(ExtractionSchemaError::DuplicateSourceChannel); + } + } + + Ok(Self { + identifier: identifier.to_owned(), + value_type, + cardinality, + required, + normalization_rule, + source_channels: seen_channels.into_iter().collect(), + }) + } + + /// Return the stable field identifier. + #[must_use] + pub fn identifier(&self) -> &str { + &self.identifier + } + + /// Return the declared value type. + #[must_use] + pub const fn value_type(&self) -> ExtractionValueType { + self.value_type + } + + /// Return the declared cardinality. + #[must_use] + pub const fn cardinality(&self) -> ExtractionCardinality { + self.cardinality + } + + /// Return whether the field must be present in a conforming extraction result. + #[must_use] + pub const fn required(&self) -> bool { + self.required + } + + /// Return the deterministic normalization rule declared for this field. + #[must_use] + pub const fn normalization_rule(&self) -> ExtractionNormalizationRule { + self.normalization_rule + } + + /// Return the reviewed source channels that may support this field. + #[must_use] + pub fn source_channels(&self) -> &[ExtractionSourceChannel] { + &self.source_channels + } +} + +/// A bounded versioned collection of typed extraction-field contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionSchema { + version: String, + fields: Vec, +} + +impl ExtractionSchema { + /// Validate and construct one versioned extraction schema. + pub fn new(version: &str, fields: Vec) -> Result { + validate_identifier(version)?; + if fields.is_empty() { + return Err(ExtractionSchemaError::MissingField); + } + if fields.len() > MAX_EXTRACTION_FIELD_COUNT { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut field_identifiers = BTreeSet::new(); + for field in &fields { + if !field_identifiers.insert(field.identifier()) { + return Err(ExtractionSchemaError::DuplicateField); + } + } + + Ok(Self { + version: version.to_owned(), + fields, + }) + } + + /// Return the immutable schema version identifier. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Return the schema's ordered field definitions. + #[must_use] + pub fn fields(&self) -> &[ExtractionField] { + &self.fields + } + + /// Find one field by its stable identifier. + #[must_use] + pub fn field(&self, identifier: &str) -> Option<&ExtractionField> { + self.fields + .iter() + .find(|field| field.identifier() == identifier) + } +} + +fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> { + if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut bytes = identifier.bytes(); + let Some(first_byte) = bytes.next() else { + return Err(ExtractionSchemaError::InvalidIdentifier); + }; + if !first_byte.is_ascii_lowercase() { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + + Ok(()) +} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 406c8be03..747dc20f0 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -7,13 +7,23 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod extraction_schema; mod sensitive_access; +mod sensitive_handle_lifecycle; +pub use extraction_schema::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema, + ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, + MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, +}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, SensitiveEvidenceError, }; +pub use sensitive_handle_lifecycle::{ + SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, +}; use std::collections::BTreeMap; @@ -296,6 +306,9 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { index += 3; continue; } + if !is_rfc3986_pchar(byte) { + return Err(EvidenceError::InvalidPath); + } segment.push(byte); index += 1; } @@ -305,6 +318,32 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { Ok(()) } +const fn is_rfc3986_pchar(byte: u8) -> bool { + matches!( + byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + const fn hexadecimal_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), diff --git a/crates/originweave-evidence/src/sensitive_access.rs b/crates/originweave-evidence/src/sensitive_access.rs index 9123119f7..24cb43047 100644 --- a/crates/originweave-evidence/src/sensitive_access.rs +++ b/crates/originweave-evidence/src/sensitive_access.rs @@ -297,7 +297,10 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> { Ok(()) } -fn valid_identifier(value: &str) -> bool { +/// Return whether `value` is a non-empty identifier of at most +/// `MAX_SENSITIVE_IDENTIFIER_BYTES` ASCII bytes, contains at least one +/// alphanumeric byte, and otherwise uses only `.`, `_`, `:`, or `-` punctuation. +pub(crate) fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs new file mode 100644 index 000000000..f61c8527f --- /dev/null +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -0,0 +1,144 @@ +//! Credential-free lifecycle evidence for opaque sensitive-value handles. +//! +//! A trusted broker can use this value object to record when a handle was +//! issued, when it expires, how many uses it permits, how many resolutions were +//! observed, and when it was revoked. The lifecycle retains the complete +//! credential-free sensitive-access receipt that authorized opaque-handle use, +//! while intentionally excluding the opaque handle token and protected value. + +use crate::sensitive_access::{ + SensitiveAccessEvidence, SensitiveAccessOutcome, SensitiveEvidenceError, +}; + +/// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. +/// +/// The embedded access receipt binds the lifecycle to the tenant, actor, task, +/// field set, purpose, destination, classification, policy version, and exact +/// opaque-handle authorization without carrying protected values. When the access +/// receipt carries a retention deadline, the handle must expire no later than +/// that deadline so derived opaque authority cannot outlive its governing receipt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidenceInput { + /// Credential-free access receipt that authorized this opaque handle. + pub access_evidence: SensitiveAccessEvidence, + /// Trusted Unix epoch second when the handle was issued. + pub issued_epoch_seconds: u64, + /// Trusted Unix epoch second after which the handle is no longer valid. + /// + /// When the retained access receipt defines a retention deadline, this value + /// may equal but must not exceed that deadline. + pub expires_epoch_seconds: u64, + /// Maximum number of broker resolutions authorized for the handle. + pub maximum_uses: u32, + /// Number of broker resolutions already observed for the handle. + pub resolution_count: u32, + /// Trusted Unix epoch second when the handle was revoked, when applicable. + /// + /// A revocation recorded exactly at expiry is retained as a terminal audit + /// event even though it cannot extend or restore handle validity. + pub revoked_epoch_seconds: Option, +} + +/// Immutable credential-free evidence about one opaque handle lifecycle. +/// +/// The value retains the exact credential-free sensitive-access receipt that +/// authorized opaque-handle use, but deliberately excludes both the opaque +/// handle token and the secret or protected value that the broker can resolve. +/// Any receipt retention deadline also bounds the derived handle lifetime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidence { + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, + expires_epoch_seconds: u64, + maximum_uses: u32, + resolution_count: u32, + revoked_epoch_seconds: Option, +} + +impl TryFrom for SensitiveHandleLifecycleEvidence { + type Error = SensitiveEvidenceError; + + fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result { + if input.access_evidence.outcome() != SensitiveAccessOutcome::OpaqueHandleOnly + || input.issued_epoch_seconds == 0 + || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() + || input.expires_epoch_seconds <= input.issued_epoch_seconds + || input + .access_evidence + .retention_deadline_epoch_seconds() + .is_some_and(|deadline| input.expires_epoch_seconds > deadline) + || input.maximum_uses == 0 + || input.resolution_count > input.maximum_uses + || input.revoked_epoch_seconds.is_some_and(|revoked| { + revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds + }) + { + return Err(SensitiveEvidenceError::InvalidLifecycle); + } + + Ok(Self { + access_evidence: input.access_evidence, + issued_epoch_seconds: input.issued_epoch_seconds, + expires_epoch_seconds: input.expires_epoch_seconds, + maximum_uses: input.maximum_uses, + resolution_count: input.resolution_count, + revoked_epoch_seconds: input.revoked_epoch_seconds, + }) + } +} + +impl SensitiveHandleLifecycleEvidence { + /// Return the credential-free access receipt that authorized this opaque handle. + #[must_use] + pub const fn access_evidence(&self) -> &SensitiveAccessEvidence { + &self.access_evidence + } + + /// Return the originating sensitive-data access request identifier. + #[must_use] + pub fn request_id(&self) -> &str { + self.access_evidence.request_id() + } + + /// Return the policy decision identifier associated with the handle. + #[must_use] + pub fn decision_id(&self) -> &str { + self.access_evidence.decision_id() + } + + /// Return the trusted handle issuance time as a Unix epoch second. + #[must_use] + pub const fn issued_epoch_seconds(&self) -> u64 { + self.issued_epoch_seconds + } + + /// Return the trusted handle expiry time as a Unix epoch second. + #[must_use] + pub const fn expires_epoch_seconds(&self) -> u64 { + self.expires_epoch_seconds + } + + /// Return the maximum number of broker resolutions authorized for the handle. + #[must_use] + pub const fn maximum_uses(&self) -> u32 { + self.maximum_uses + } + + /// Return the number of broker resolutions already observed for the handle. + #[must_use] + pub const fn resolution_count(&self) -> u32 { + self.resolution_count + } + + /// Return the trusted revocation time when the handle has been revoked. + #[must_use] + pub const fn revoked_epoch_seconds(&self) -> Option { + self.revoked_epoch_seconds + } + + /// Return whether trusted evidence records that this handle was revoked. + #[must_use] + pub const fn is_revoked(&self) -> bool { + self.revoked_epoch_seconds.is_some() + } +} diff --git a/crates/originweave-evidence/tests/evidence.rs b/crates/originweave-evidence/tests/evidence.rs index 2912180f1..48d49cbc4 100644 --- a/crates/originweave-evidence/tests/evidence.rs +++ b/crates/originweave-evidence/tests/evidence.rs @@ -80,6 +80,9 @@ fn network_evidence_rejects_non_path_inputs() { "/bad\npath", "/bad path", "/windows\\path", + "/[segment]", + "/raw|pipe", + "/raw-한글", ] { assert_eq!( NetworkEvidence::capture( @@ -128,6 +131,7 @@ fn provenance_rejects_credential_bearing_or_ambiguous_source_urls() { "https://example.com/bad\\path", "https://example.com/\n", "https://example.com/a/%2f/b", + "https://example.com/[segment]", ] { assert_eq!( ProvenanceRecord::new( diff --git a/crates/originweave-evidence/tests/extraction_normalization.rs b/crates/originweave-evidence/tests/extraction_normalization.rs new file mode 100644 index 000000000..63afd39e6 --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_normalization.rs @@ -0,0 +1,77 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn extraction_fields_require_an_explicit_typed_normalization_rule() +-> Result<(), ExtractionSchemaError> { + let text = ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert_eq!( + text.normalization_rule(), + ExtractionNormalizationRule::TrimTextWhitespace + ); + + let timestamp = ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::NetworkResponse], + )?; + assert_eq!( + timestamp.normalization_rule(), + ExtractionNormalizationRule::Rfc3339Utc + ); + Ok(()) +} + +#[test] +fn extraction_fields_fail_closed_on_type_incompatible_normalization() { + assert_eq!( + ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::NetworkResponse], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); + assert_eq!( + ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); +} + +#[test] +fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> { + let field = ExtractionField::new( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + field.normalization_rule(), + ExtractionNormalizationRule::Verbatim + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs new file mode 100644 index 000000000..fc875ef0f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -0,0 +1,326 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, + MAX_EXTRACTION_IDENTIFIER_BYTES, +}; + +fn field( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], +) -> Result { + ExtractionField::new( + identifier, + value_type, + cardinality, + required, + source_channels, + ) +} + +#[test] +fn schema_binds_versioned_typed_fields_to_explicit_source_channels() +-> Result<(), ExtractionSchemaError> { + let schema = ExtractionSchema::new( + "product-card-v1", + vec![ + field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ], + )?, + field( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ + ExtractionSourceChannel::TableCell, + ExtractionSourceChannel::NetworkResponse, + ], + )?, + ], + )?; + + assert_eq!(schema.version(), "product-card-v1"); + assert_eq!(schema.fields().len(), 2); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::identifier), + Some("product_name") + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::value_type), + Some(ExtractionValueType::Text) + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::cardinality), + Some(ExtractionCardinality::One) + ); + assert_eq!( + schema.field("product_name").map(ExtractionField::required), + Some(true) + ); + let expected_product_sources = [ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ]; + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::source_channels), + Some(expected_product_sources.as_slice()) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::value_type), + Some(ExtractionValueType::Decimal) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::cardinality), + Some(ExtractionCardinality::ZeroOrOne) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::required), + Some(false) + ); + assert!(schema.field("missing_field").is_none()); + Ok(()) +} + +#[test] +fn field_accepts_all_reviewed_value_and_source_channel_variants() +-> Result<(), ExtractionSchemaError> { + let cases = [ + ( + ExtractionValueType::Text, + ExtractionSourceChannel::SemanticNode, + ), + ( + ExtractionValueType::Integer, + ExtractionSourceChannel::StructuredData, + ), + ( + ExtractionValueType::Decimal, + ExtractionSourceChannel::TableCell, + ), + ( + ExtractionValueType::Boolean, + ExtractionSourceChannel::NetworkResponse, + ), + ( + ExtractionValueType::Timestamp, + ExtractionSourceChannel::ModelInterpretation, + ), + ]; + + for (index, (value_type, source_channel)) in cases.into_iter().enumerate() { + let field = field( + &format!("field_{index}"), + value_type, + ExtractionCardinality::Many, + false, + &[source_channel], + )?; + assert_eq!(field.value_type(), value_type); + assert_eq!(field.cardinality(), ExtractionCardinality::Many); + assert_eq!(field.source_channels(), &[source_channel]); + } + + let required_many = field( + "required_many", + ExtractionValueType::Text, + ExtractionCardinality::Many, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert!(required_many.required()); + Ok(()) +} + +#[test] +fn field_rejects_contradictory_required_cardinality_contracts() { + assert_eq!( + ExtractionField::new( + "optional_exactly_one", + ExtractionValueType::Text, + ExtractionCardinality::One, + false, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); + assert_eq!( + ExtractionField::new( + "required_zero_or_one", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); +} + +#[test] +fn field_rejects_empty_malformed_or_overlong_identifiers() { + assert_eq!( + ExtractionField::new( + "", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "Product Name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "product name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "1product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); +} + +#[test] +fn field_requires_a_nonempty_duplicate_free_source_channel_set() { + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[], + ), + Err(ExtractionSchemaError::MissingSourceChannel) + ); + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::SemanticNode, + ], + ), + Err(ExtractionSchemaError::DuplicateSourceChannel) + ); +} + +#[test] +fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() +-> Result<(), ExtractionSchemaError> { + assert_eq!( + ExtractionSchema::new( + "Product Schema", + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?] + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionSchema::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![]), + Err(ExtractionSchemaError::MissingField) + ); + + let duplicate = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + let duplicate_again = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![duplicate, duplicate_again]), + Err(ExtractionSchemaError::DuplicateField) + ); + + let too_many_fields = (0..=MAX_EXTRACTION_FIELD_COUNT) + .map(|index| { + field( + &format!("field_{index}"), + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::SemanticNode], + ) + }) + .collect::, _>>()?; + assert_eq!( + ExtractionSchema::new("product-card-v1", too_many_fields), + Err(ExtractionSchemaError::LimitExceeded) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs new file mode 100644 index 000000000..b4897d90f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs @@ -0,0 +1,48 @@ +use std::error::Error as _; + +use originweave_evidence::ExtractionSchemaError; + +fn assert_standard_error_contract() {} + +#[test] +fn extraction_schema_errors_implement_standard_error_contract() { + assert_standard_error_contract::(); + + for (error, message) in [ + ( + ExtractionSchemaError::InvalidIdentifier, + "invalid extraction schema or field identifier", + ), + ( + ExtractionSchemaError::LimitExceeded, + "extraction schema limit exceeded", + ), + ( + ExtractionSchemaError::InvalidCardinalityRequirement, + "extraction field required flag is incompatible with the declared cardinality", + ), + ( + ExtractionSchemaError::MissingSourceChannel, + "extraction field requires at least one source channel", + ), + ( + ExtractionSchemaError::DuplicateSourceChannel, + "extraction field contains a duplicate source channel", + ), + ( + ExtractionSchemaError::InvalidNormalizationRule, + "extraction normalization rule is incompatible with the field value type", + ), + ( + ExtractionSchemaError::MissingField, + "extraction schema requires at least one field", + ), + ( + ExtractionSchemaError::DuplicateField, + "extraction schema contains a duplicate field identifier", + ), + ] { + assert_eq!(error.to_string(), message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-evidence/tests/extraction_source_channel_set.rs b/crates/originweave-evidence/tests/extraction_source_channel_set.rs new file mode 100644 index 000000000..1f5070e8a --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_source_channel_set.rs @@ -0,0 +1,40 @@ +#![allow(clippy::expect_used)] + +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn equivalent_source_channel_sets_have_canonical_identity() { + let semantic_then_network = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ], + ) + .expect("reviewed source set must be valid"); + let network_then_semantic = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::NetworkResponse, + ExtractionSourceChannel::SemanticNode, + ], + ) + .expect("equivalent reviewed source set must be valid"); + + assert_eq!(semantic_then_network, network_then_semantic); + assert_eq!( + network_then_semantic.source_channels(), + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ] + ); +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs new file mode 100644 index 000000000..6dbf8d713 --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -0,0 +1,114 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn access_evidence( + outcome: SensitiveAccessOutcome, + decision_epoch_seconds: u64, +) -> Result { + let destination = + Origin::parse("https://checkout.example.com").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-browser-adapter".to_owned(), + task_id: "task-99".to_owned(), + field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn lifecycle_input( + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, +) -> SensitiveHandleLifecycleEvidenceInput { + SensitiveHandleLifecycleEvidenceInput { + access_evidence, + issued_epoch_seconds, + expires_epoch_seconds: issued_epoch_seconds + 300, + maximum_uses: 2, + resolution_count: 0, + revoked_epoch_seconds: None, + } +} + +#[test] +fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let evidence = + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001)) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.access_evidence(), &access); + assert_eq!(evidence.request_id(), access.request_id()); + assert_eq!(evidence.decision_id(), access.decision_id()); + assert_eq!(evidence.access_evidence().tenant_id(), "tenant-7"); + assert_eq!(evidence.access_evidence().task_id(), "task-99"); + assert_eq!( + evidence.access_evidence().field_ids(), + ["shipping_name", "shipping_address"] + ); + assert_eq!( + evidence.access_evidence().destination().as_str(), + "https://checkout.example.com" + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_non_opaque_handle_access_decision() -> TestResult { + let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(denied, 1_720_000_001)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access, 1_720_000_099)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_expiry_respects_access_retention_deadline() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let retention_deadline = access + .retention_deadline_epoch_seconds() + .ok_or_else(|| "fixture must carry a retention deadline".to_owned())?; + + let mut exact_deadline = lifecycle_input(access.clone(), 1_720_000_001); + exact_deadline.expires_epoch_seconds = retention_deadline; + SensitiveHandleLifecycleEvidence::try_from(exact_deadline) + .map_err(|error| format!("{error:?}"))?; + + let mut after_deadline = lifecycle_input(access, 1_720_000_001); + after_deadline.expires_epoch_seconds = retention_deadline + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(after_deadline), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs new file mode 100644 index 000000000..95034cecc --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -0,0 +1,142 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn valid_access_evidence() -> Result { + let destination = + Origin::parse("https://shipping.example").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-fulfillment".to_owned(), + task_id: "task-42".to_owned(), + field_ids: vec!["shipping.address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome: SensitiveAccessOutcome::OpaqueHandleOnly, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds: 1_720_000_000, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(1_720_003_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn valid_input() -> Result { + Ok(SensitiveHandleLifecycleEvidenceInput { + access_evidence: valid_access_evidence()?, + issued_epoch_seconds: 1_720_000_001, + expires_epoch_seconds: 1_720_000_301, + maximum_uses: 2, + resolution_count: 1, + revoked_epoch_seconds: None, + }) +} + +#[test] +fn records_bounded_handle_lifecycle_without_handle_or_secret_material() -> TestResult { + let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()?) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.request_id(), "request-42"); + assert_eq!(evidence.decision_id(), "decision-42"); + assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_001); + assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_301); + assert_eq!(evidence.maximum_uses(), 2); + assert_eq!(evidence.resolution_count(), 1); + assert_eq!(evidence.revoked_epoch_seconds(), None); + assert!(!evidence.is_revoked()); + + let debug = format!("{evidence:?}"); + assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); + assert!(!debug.contains("raw-secret-should-never-be-evidence")); + Ok(()) +} + +#[test] +fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(1_720_000_120); + input.resolution_count = 2; + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); + assert!(evidence.is_revoked()); + assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); + Ok(()) +} + +#[test] +fn records_revocation_at_exact_expiry_boundary() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(input.expires_epoch_seconds); + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!( + evidence.revoked_epoch_seconds(), + Some(evidence.expires_epoch_seconds()) + ); + assert!(evidence.is_revoked()); + Ok(()) +} + +#[test] +fn rejects_zero_or_non_increasing_handle_lifetime() -> TestResult { + for (issued, expires) in [ + (0, 1_720_000_301), + (1_720_000_301, 1_720_000_301), + (1_720_000_302, 1_720_000_301), + ] { + let mut input = valid_input()?; + input.issued_epoch_seconds = issued; + input.expires_epoch_seconds = expires; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} + +#[test] +fn rejects_zero_use_limit_or_resolution_count_above_limit() -> TestResult { + let mut zero_limit = valid_input()?; + zero_limit.maximum_uses = 0; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(zero_limit), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + + let mut overused = valid_input()?; + overused.resolution_count = overused.maximum_uses + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(overused), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn rejects_revocation_before_issue_or_after_expiry() -> TestResult { + for revoked in [1_720_000_000, 1_720_000_302] { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(revoked); + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index a77d9b794..bf8c21ca9 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -4,8 +4,9 @@ //! without hostname resolution or proxy inheritance, verifies operating-system //! peers before exposing transport I/O, and emits credential-free evidence. //! It also bridges a session-correlated WebDriver BiDi loopback target from -//! `originweave-core` into one bounded exact TCP connection and can bind an inert -//! RFC 6455 opening request to an already-verified plain BiDi stream without +//! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 +//! opening request to that verified plain stream, and can write that exact request +//! under one bounded deadline without claiming a completed WebSocket handshake or //! granting browser, WebSocket, TLS, policy, or Agent authority. #![forbid(unsafe_code)] @@ -14,6 +15,7 @@ mod connection; mod webdriver_bidi_connection; mod webdriver_bidi_websocket_handshake; +mod webdriver_bidi_websocket_opening_recovery; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -24,6 +26,8 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakePlan, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, }; +pub use webdriver_bidi_websocket_opening_recovery::WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 026fa390b..822f4154c 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,12 +1,24 @@ -use std::fmt; +use std::{ + error::Error, + fmt, + io::{self, Write}, + net::TcpStream, + time::{Duration, Instant}, +}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; -use crate::WebDriverBiDiTcpConnection; +use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; +/// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is +/// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') } @@ -42,7 +54,7 @@ impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { } } -impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} +impl Error for WebDriverBiDiWebSocketHandshakeError {} /// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. /// @@ -152,4 +164,626 @@ impl WebDriverBiDiWebSocketHandshakePlan { pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { self.connection.verified_peer() } + + /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. + pub fn write_opening_request( + self, + write_timeout: Duration, + ) -> Result + { + if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }, + ); + } + + let Self { + connection, + client_key, + request, + } = self; + let (mut stream, transport_evidence) = connection.into_parts(); + let mut now = Instant::now; + let request_byte_count = + write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketOpeningRequestSent { + stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + }) + } +} + +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. +pub struct WebDriverBiDiWebSocketOpeningRequestSent { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + request_byte_count: usize, + write_timeout: Duration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() + } +} + +impl WebDriverBiDiWebSocketOpeningRequestSent { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + &self.transport_evidence + } + + /// Borrow the exact client key required to validate the later server accept value. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Return the exact number of opening-request bytes written before success was emitted. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.request_byte_count + } + + /// Return the total write deadline configured for this opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.write_timeout + } +} + +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, + request: &[u8], + write_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); + } + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } + Err(source) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + + Ok(bytes_written) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod opening_write_tests { + use super::*; + use std::{collections::VecDeque, net::TcpListener, sync::mpsc, thread}; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + clear_timeout_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + clear_timeout_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + #[test] + fn bounded_writer_completes_partial_and_interrupted_writes() { + let mut writer = FakeWriter::new([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let mut now = || times.pop_front().unwrap_or(start); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); + } + + fn join_loopback_server(server: thread::JoinHandle>) -> bool { + match server.join() { + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, + } + } + + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let (release_server, await_release) = mpsc::sync_channel(0); + let server = thread::spawn(move || { + let (_stream, _peer) = listener.accept().expect("test server must accept client"); + await_release.recv().map_err(io::Error::other) + }); + let mut stream = TcpStream::connect(address).expect("test client must connect"); + let start = Instant::now(); + let mut now = || start; + + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + release_server + .send(()) + .expect("the loopback server must remain available through timeout cleanup"); + assert!(!join_loopback_server(server)); + } + + #[test] + fn panicked_loopback_server_is_reported() { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + assert!(join_loopback_server(server)); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut now = || start; + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + source: io::Error::from(kind), + } + ))); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now, + ); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + assert!(failed.source().is_some()); + assert!(cleanup.source().is_some()); + } } diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_opening_recovery.rs b/crates/originweave-network/src/webdriver_bidi_websocket_opening_recovery.rs new file mode 100644 index 000000000..97322f56f --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_opening_recovery.rs @@ -0,0 +1,145 @@ +use crate::webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketOpeningWriteError; + +/// Required recovery posture after a failed WebDriver BiDi WebSocket opening-request write. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition { + /// No complete opening request is known to have been submitted. + /// + /// This is not automatic retry permission. The caller must obtain fresh authority, route, + /// connection, and deadline validation before starting another opening request. + RevalidateBeforeNewAttempt, + /// The peer may already have received the complete opening request, or byte accounting is + /// inconsistent with the exact serialized request length. + /// + /// Blind redispatch is forbidden until the caller reconciles the potentially completed + /// external side effect. + ReconciliationRequired, +} + +impl WebDriverBiDiWebSocketOpeningWriteError { + /// Classify the fail-closed recovery posture for this failed opening-request write. + /// + /// `request_byte_count` must be the exact serialized length of the request whose write produced + /// this error. A zero request length, complete-or-greater byte count, or timeout-cleanup failure + /// is treated as ambiguous external completion and therefore requires reconciliation. + #[must_use] + pub fn recovery_disposition( + &self, + request_byte_count: usize, + ) -> WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition { + use WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::{ + ReconciliationRequired, RevalidateBeforeNewAttempt, + }; + + if request_byte_count == 0 { + return ReconciliationRequired; + } + + match self { + Self::InvalidWriteTimeout { .. } => RevalidateBeforeNewAttempt, + Self::WriteTimeoutCleanupFailed { .. } => ReconciliationRequired, + Self::WriteDeadlineExceeded { bytes_written } + | Self::WriteTimeoutConfigurationFailed { bytes_written, .. } + | Self::WriteTimedOut { bytes_written, .. } + | Self::WriteZero { bytes_written } + | Self::WriteFailed { bytes_written, .. } => { + if *bytes_written >= request_byte_count { + ReconciliationRequired + } else { + RevalidateBeforeNewAttempt + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::{io, time::Duration}; + + use super::WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::{ + ReconciliationRequired, RevalidateBeforeNewAttempt, + }; + use crate::WebDriverBiDiWebSocketOpeningWriteError; + + #[test] + fn ambiguous_or_complete_opening_writes_require_reconciliation() { + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 16 } + .recovery_disposition(16), + ReconciliationRequired + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 17, + source: io::Error::other("write completion accounting exceeded request length"), + } + .recovery_disposition(16), + ReconciliationRequired + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 16, + source: io::Error::other("write timeout cleanup failed after request completion"), + } + .recovery_disposition(16), + ReconciliationRequired + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: Duration::from_secs(5), + } + .recovery_disposition(0), + ReconciliationRequired + ); + } + + #[test] + fn incomplete_opening_writes_require_fresh_revalidation_before_another_attempt() { + let request_byte_count = 16; + + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: Duration::from_secs(5), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 3 } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 3, + source: io::Error::other("timeout configuration failed"), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 3, + source: io::Error::from(io::ErrorKind::TimedOut), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 3 } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 3, + source: io::Error::other("write failed before request completion"), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + } +} diff --git a/crates/originweave-network/tests/opening_write_recovery_disposition.rs b/crates/originweave-network/tests/opening_write_recovery_disposition.rs new file mode 100644 index 000000000..67c8257d0 --- /dev/null +++ b/crates/originweave-network/tests/opening_write_recovery_disposition.rs @@ -0,0 +1,54 @@ +use std::io; + +use originweave_network::{ + WebDriverBiDiWebSocketOpeningWriteError, WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition, +}; + +#[test] +fn complete_or_cleanup_failed_opening_write_requires_reconciliation_before_retry() { + let completed_after_deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 128 }; + assert_eq!( + completed_after_deadline.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); + + let cleanup_failed = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 128, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + assert_eq!( + cleanup_failed.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); +} + +#[test] +fn partial_or_inconsistent_opening_write_failure_stays_fail_closed() { + let partial_deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 64 }; + assert_eq!( + partial_deadline.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::RevalidateBeforeNewAttempt + ); + + let partial_timeout = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 64, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + assert_eq!( + partial_timeout.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::RevalidateBeforeNewAttempt + ); + + let impossible_count = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 129 }; + assert_eq!( + impossible_count.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); + assert_eq!( + partial_deadline.recovery_disposition(0), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 93550245a..a08549581 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -1,9 +1,15 @@ -use std::{net::TcpListener, thread, time::Duration}; +use std::{ + net::{Shutdown, TcpListener}, + sync::mpsc, + thread, + time::Duration, +}; use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -133,6 +139,63 @@ fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { } } +#[test] +fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let (release_server, await_release) = mpsc::sync_channel(0); + let server = thread::spawn(move || { + let accepted = listener.accept()?; + await_release.recv().map_err(std::io::Error::other)?; + drop(accepted); + Ok::<(), std::io::Error>(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let shutdown = connection.stream().shutdown(Shutdown::Both); + assert!(shutdown.is_ok(), "{shutdown:?}"); + + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let write = plan.write_opening_request(Duration::from_secs(1)); + let failed_closed_without_writing = match write { + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, .. + }) => true, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }) => source.kind() == std::io::ErrorKind::InvalidInput, + _ => false, + }; + assert!(failed_closed_without_writing); + assert!(release_server.send(()).is_ok()); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn handshake_errors_render_actionable_fail_closed_messages() { assert_eq!( diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs new file mode 100644 index 000000000..433774dd4 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -0,0 +1,173 @@ +use std::{ + io::{self, Read}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningWriteError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +fn read_opening_request(mut stream: std::net::TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +#[test] +fn bounded_opening_write_sends_exact_request_and_preserves_transport_evidence() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || { + let accepted = listener.accept()?; + read_opening_request(accepted.0) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let expected = format!( + "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + let write_timeout = Duration::from_millis(500); + let written = plan.write_opening_request(write_timeout); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + assert_eq!(written.request_byte_count(), expected.len()); + assert_eq!(written.write_timeout(), write_timeout); + assert_eq!(written.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!( + written.transport_evidence().verified_peer().socket_addr(), + local_addr + ); + assert_eq!( + written.transport_evidence().verified_peer().session_id(), + SESSION_ID + ); + assert_eq!(written.transport_evidence().attempt_number(), 1); + let debug = format!("{written:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketOpeningRequestSent")); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(received) = server_result { + assert!(received.is_ok(), "{received:?}"); + if let Ok(received) = received { + assert_eq!(received, expected.as_bytes()); + } + } +} + +#[test] +fn opening_write_rejects_zero_and_excessive_deadlines_before_success_evidence() { + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT + Duration::from_nanos(1), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + continue; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + continue; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + continue; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + continue; + }; + + let result = plan.write_opening_request(timeout); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }) if write_timeout == timeout + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } + } +} diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 243ae8ce7..dbfb3c16d 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -15,6 +15,7 @@ pub use sensitive_data::{ evaluate_handle_use, }; +use originweave_core::mcp::ValidatedMcpToolCall; use originweave_core::{ ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, @@ -40,6 +41,8 @@ pub enum DenialReason { ModePurposeMismatch, /// Page or document content attempted to become a trusted instruction. UntrustedInstructionSource, + /// The validated MCP route resolved to a different action than the policy request. + McpActionMismatch, /// The session lacks the exact capability required by the action. MissingCapability(Capability), /// The target origin is outside the session's read grant. @@ -66,6 +69,23 @@ pub enum DenialReason { ApprovalScopeMismatch, } +/// Evaluate a policy request only when it matches an already validated MCP route. +/// +/// Matching routing metadata grants no authority. Once route and request action agree, the request +/// still passes through the existing action policy unchanged. +#[must_use] +pub fn evaluate_mcp( + call: &ValidatedMcpToolCall, + request: &ActionRequest, + context: &PolicyContext, +) -> Decision { + if call.action_kind() != request.action() { + return Decision::Deny(DenialReason::McpActionMismatch); + } + + evaluate(request, context) +} + /// Evaluate a typed browser action against one explicit policy context. #[must_use] pub fn evaluate(request: &ActionRequest, context: &PolicyContext) -> Decision { diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs new file mode 100644 index 000000000..48d7936e1 --- /dev/null +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -0,0 +1,343 @@ +#![allow(clippy::expect_used)] + +//! Keep extension proposal-grant evaluation separate from ordinary action policy. +//! +//! OriginWeave does not yet implement an adapter that converts an extension proposal into an +//! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: +//! the exact extension/session/context/origin/unexpired grant permits only `ProposeTypedAction`, +//! while an ordinary user-sourced action request remains subject to the core policy decision +//! shown in each test. + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(17).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(23).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_cross_origin_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let source = origin("https://source.example"); + let target = origin("https://target.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([source.clone(), target.clone()]), + BTreeSet::from([target.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + source, + target, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrossOriginMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_write_origin_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotWritable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_crawler_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrawlerMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_mode_purpose_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ModePurposeMismatch) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_disallowed_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Disallowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsDisallowed) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_unknown_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Unknown, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsUnknown) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_missing_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsNotApplicable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_non_delegable_r5_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://consent.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::LegalConsent]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::LegalConsent, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ForbiddenRisk) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_human_mode_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://human.example"); + let context = PolicyContext::new( + SessionMode::Human, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::HumanModeNotAgentControlled) + ); +} diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs new file mode 100644 index 000000000..f32d8733c --- /dev/null +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -0,0 +1,215 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn explicit_extension_grant_does_not_widen_agent_origin_authority() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let allowed = origin("https://app.example"); + let forbidden = origin("https://outside.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([allowed.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + allowed, + forbidden, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotReadable) + ); +} + +#[test] +fn explicit_extension_grant_does_not_supply_agent_action_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} + +#[test] +fn untrusted_extension_content_cannot_become_a_policy_instruction() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::WebContent, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UntrustedInstructionSource) + ); +} + +#[test] +fn explicit_extension_grant_cannot_turn_raw_secret_delivery_into_a_fill_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::SecretBrokerRequired) + ); +} + +#[test] +fn explicit_extension_grant_cannot_attach_secret_material_to_non_secret_action() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UnexpectedSecretMaterial) + ); +} diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs new file mode 100644 index 000000000..f808bec04 --- /dev/null +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -0,0 +1,96 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_access, +}; +use originweave_policy::{Decision, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin() -> Origin { + Origin::parse("https://login.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +fn secret_context(site: &Origin) -> PolicyContext { + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn extension_action_grant_cannot_skip_secret_broker_approval() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin(); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site.clone(), + InstructionSource::User, + SecretDelivery::BrokerHandle, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &secret_context(&site)), + Decision::RequireApproval(RiskClass::R3) + ); +} diff --git a/crates/originweave-policy/tests/mcp_route_binding.rs b/crates/originweave-policy/tests/mcp_route_binding.rs new file mode 100644 index 000000000..8e9661af6 --- /dev/null +++ b/crates/originweave-policy/tests/mcp_route_binding.rs @@ -0,0 +1,96 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::mcp::{MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, ValidatedMcpToolCall}; +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, Capability, ExecutionPurpose, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, +}; +use originweave_policy::{Decision, DenialReason, evaluate_mcp}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn origin() -> Origin { + Origin::parse("https://mcp.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn validated_call(tool_name: &str) -> ValidatedMcpToolCall { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) + .expect("known test MCP tool") +} + +fn request(action: ActionKind) -> ActionRequest { + let site = origin(); + ActionRequest::new( + action, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ) +} + +fn context(capabilities: BTreeSet) -> PolicyContext { + let site = origin(); + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + capabilities, + BTreeSet::from([site.clone()]), + BTreeSet::from([site]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn matching_mcp_route_enters_the_existing_policy_boundary() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Observe), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!(decision, Decision::Allow); +} + +#[test] +fn mismatched_mcp_route_cannot_be_reinterpreted_as_another_action() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Navigate])), + ); + + assert_eq!(decision, Decision::Deny(DenialReason::McpActionMismatch)); +} + +#[test] +fn matching_mcp_route_does_not_bypass_existing_policy_denials() { + let call = validated_call("originweave.navigate"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!( + decision, + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} diff --git a/crates/originweave-resource/src/lib.rs b/crates/originweave-resource/src/lib.rs index 8c77aa3d0..35a30789a 100644 --- a/crates/originweave-resource/src/lib.rs +++ b/crates/originweave-resource/src/lib.rs @@ -9,6 +9,8 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +use std::fmt; + /// A validation error in a resource budget. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BudgetError { @@ -18,6 +20,19 @@ pub enum BudgetError { SoftExceedsHard, } +impl fmt::Display for BudgetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroLimit => formatter.write_str("resource budget limits must be nonzero"), + Self::SoftExceedsHard => { + formatter.write_str("resource budget soft limits must not exceed hard limits") + } + } + } +} + +impl std::error::Error for BudgetError {} + /// Validated resource limits for one agent task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResourceBudget { diff --git a/crates/originweave-resource/tests/error_contract.rs b/crates/originweave-resource/tests/error_contract.rs new file mode 100644 index 000000000..cc8b88dfb --- /dev/null +++ b/crates/originweave-resource/tests/error_contract.rs @@ -0,0 +1,21 @@ +use originweave_resource::BudgetError; +use std::error::Error as _; + +#[test] +fn budget_errors_expose_stable_standard_error_contract() { + let cases = [ + ( + BudgetError::ZeroLimit, + "resource budget limits must be nonzero", + ), + ( + BudgetError::SoftExceedsHard, + "resource budget soft limits must not exceed hard limits", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-tls/src/lib.rs b/crates/originweave-tls/src/lib.rs index f9ec5e877..9024946f4 100644 --- a/crates/originweave-tls/src/lib.rs +++ b/crates/originweave-tls/src/lib.rs @@ -14,6 +14,7 @@ mod evidence; mod handshake; mod identity; mod policy; +mod revocation; mod trust; mod validity; @@ -29,6 +30,7 @@ pub use policy::{ MAX_MINIMUM_LEAF_VALIDITY, MAX_SERVER_CERTIFICATE_BYTES, MAX_SERVER_CERTIFICATE_COUNT, MAX_TLS_HANDSHAKE_TIMEOUT, TlsClientPolicy, }; +pub use revocation::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; pub use trust::{ MAX_TRUST_ROOT_BYTES, MAX_TRUST_ROOT_COUNT, TrustBundleIdentifier, TrustRootBundle, }; diff --git a/crates/originweave-tls/src/revocation.rs b/crates/originweave-tls/src/revocation.rs new file mode 100644 index 000000000..e500125a2 --- /dev/null +++ b/crates/originweave-tls/src/revocation.rs @@ -0,0 +1,174 @@ +use std::fmt; + +/// A deterministic freshness window for independently verified revocation material. +/// +/// This value does not fetch, parse, authenticate, or interpret OCSP responses or +/// certificate revocation lists. A trusted adapter must first obtain and +/// cryptographically validate the revocation material, then pass the signed +/// `thisUpdate` and `nextUpdate` timestamps into this authority together with a +/// caller-selected local maximum freshness window. Passing this check proves only +/// that the supplied material is within both its signed interval and the caller's +/// bounded freshness policy; it does not prove that any certificate is unrevoked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RevocationMaterialFreshness { + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, +} + +impl RevocationMaterialFreshness { + /// Create a non-empty, locally bounded freshness window from trusted signed timestamps. + /// + /// The signed window is half-open: `thisUpdate <= trusted_time < nextUpdate`. + /// Equal or reversed timestamps fail closed because they provide no usable + /// interval. `maximum_window_seconds` is a separate local policy ceiling and + /// must be nonzero; signed material whose declared interval exceeds that + /// ceiling is rejected even if its timestamps are otherwise well-formed. + pub const fn new( + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, + ) -> Result { + if next_update_unix_seconds <= this_update_unix_seconds { + return Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + }); + } + if maximum_window_seconds == 0 { + return Err(RevocationMaterialFreshnessError::ZeroMaximumWindow); + } + + let window_seconds = next_update_unix_seconds - this_update_unix_seconds; + if window_seconds > maximum_window_seconds { + return Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + }); + } + + Ok(Self { + this_update_unix_seconds, + next_update_unix_seconds, + maximum_window_seconds, + }) + } + + /// Return the signed time at which the revocation material becomes current. + #[must_use] + pub const fn this_update_unix_seconds(self) -> u64 { + self.this_update_unix_seconds + } + + /// Return the signed time at which this freshness window stops being usable. + #[must_use] + pub const fn next_update_unix_seconds(self) -> u64 { + self.next_update_unix_seconds + } + + /// Return the caller-selected maximum accepted signed-window duration. + #[must_use] + pub const fn maximum_window_seconds(self) -> u64 { + self.maximum_window_seconds + } + + /// Evaluate one trusted time against the half-open freshness window. + /// + /// A time before `thisUpdate` is not yet usable. A time equal to or later + /// than `nextUpdate` is stale. Both cases fail closed without making any + /// statement about the certificate's revocation state. + pub const fn evaluate( + self, + trusted_time_unix_seconds: u64, + ) -> Result<(), RevocationMaterialFreshnessError> { + if trusted_time_unix_seconds < self.this_update_unix_seconds { + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds: self.this_update_unix_seconds, + }) + } else if trusted_time_unix_seconds >= self.next_update_unix_seconds { + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds: self.next_update_unix_seconds, + }) + } else { + Ok(()) + } + } +} + +/// A deterministic reason that verified revocation material is not fresh enough to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevocationMaterialFreshnessError { + /// The supplied signed timestamps do not define a non-empty freshness window. + InvalidWindow { + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, + /// The caller supplied no positive local maximum freshness duration. + ZeroMaximumWindow, + /// The material's signed interval exceeds the caller's local freshness ceiling. + WindowExceedsMaximum { + /// Duration of the signed `thisUpdate` to `nextUpdate` interval in seconds. + window_seconds: u64, + /// Caller-selected maximum accepted interval in seconds. + maximum_window_seconds: u64, + }, + /// Trusted time falls before the material's signed `thisUpdate` timestamp. + NotYetValid { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + }, + /// Trusted time is equal to or later than the material's signed `nextUpdate` timestamp. + Expired { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, +} + +impl fmt::Display for RevocationMaterialFreshnessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material window is invalid: thisUpdate {this_update_unix_seconds} must be before nextUpdate {next_update_unix_seconds}", + ), + Self::ZeroMaximumWindow => write!( + formatter, + "revocation material maximum freshness window must be greater than zero", + ), + Self::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + } => write!( + formatter, + "revocation material window is {window_seconds} seconds, exceeding the local maximum of {maximum_window_seconds} seconds", + ), + Self::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds, + } => write!( + formatter, + "revocation material is not usable at trusted time {trusted_time_unix_seconds}; thisUpdate is {this_update_unix_seconds}", + ), + Self::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material is stale at trusted time {trusted_time_unix_seconds}; nextUpdate is {next_update_unix_seconds}", + ), + } + } +} + +impl std::error::Error for RevocationMaterialFreshnessError {} diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index f3e3374b6..32aa66e17 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -19,6 +19,7 @@ impl TrustBundleIdentifier { pub fn parse(input: &str) -> Result { if input.is_empty() || input.len() > 128 + || !input.bytes().any(|byte| byte.is_ascii_alphanumeric()) || !input.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') }) diff --git a/crates/originweave-tls/tests/policy_contract.rs b/crates/originweave-tls/tests/policy_contract.rs index 5a8b71ef4..4fad353b3 100644 --- a/crates/originweave-tls/tests/policy_contract.rs +++ b/crates/originweave-tls/tests/policy_contract.rs @@ -25,7 +25,7 @@ fn trust_bundle_identifier_is_bounded_and_ascii() { TrustBundleIdentifier::parse("enterprise_roots:v1").expect("valid trust bundle identifier"); assert_eq!(identifier.as_str(), "enterprise_roots:v1"); - for invalid in ["", "contains space", "한글", "slash/value"] { + for invalid in ["", "contains space", "한글", "slash/value", "---"] { assert!(matches!( TrustBundleIdentifier::parse(invalid), Err(TlsError::InvalidTrustBundleIdentifier) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs new file mode 100644 index 000000000..c7af7bd7c --- /dev/null +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -0,0 +1,119 @@ +use std::error::Error as _; + +use originweave_tls::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; + +const MAXIMUM_WINDOW_SECONDS: u64 = 300; + +#[test] +fn revocation_material_freshness_uses_a_half_open_verified_window() { + let freshness = RevocationMaterialFreshness::new(1_000, 1_100, MAXIMUM_WINDOW_SECONDS); + assert!(freshness.is_ok()); + + if let Ok(freshness) = freshness { + assert_eq!(freshness.this_update_unix_seconds(), 1_000); + assert_eq!(freshness.next_update_unix_seconds(), 1_100); + assert_eq!(freshness.maximum_window_seconds(), MAXIMUM_WINDOW_SECONDS); + assert_eq!(freshness.evaluate(1_000), Ok(())); + assert_eq!(freshness.evaluate(1_099), Ok(())); + assert_eq!( + freshness.evaluate(999), + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }) + ); + assert_eq!( + freshness.evaluate(1_100), + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_rejects_empty_or_reversed_windows() { + for (this_update, next_update) in [(1_000, 1_000), (1_001, 1_000)] { + assert_eq!( + RevocationMaterialFreshness::new(this_update, next_update, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: this_update, + next_update_unix_seconds: next_update, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_requires_a_bounded_local_policy_window() { + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_100, 0), + Err(RevocationMaterialFreshnessError::ZeroMaximumWindow) + ); + + let exact_maximum = RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); + assert!(exact_maximum.is_ok()); + + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_301, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }) + ); + + assert_eq!( + RevocationMaterialFreshness::new(1, u64::MAX, 1), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: u64::MAX - 1, + maximum_window_seconds: 1, + }) + ); +} + +#[test] +fn revocation_freshness_errors_are_stable_and_source_free() { + let invalid = RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: 1_000, + next_update_unix_seconds: 1_000, + }; + let zero_maximum = RevocationMaterialFreshnessError::ZeroMaximumWindow; + let too_long = RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }; + let future = RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }; + let stale = RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }; + + assert_eq!( + invalid.to_string(), + "revocation material window is invalid: thisUpdate 1000 must be before nextUpdate 1000" + ); + assert_eq!( + zero_maximum.to_string(), + "revocation material maximum freshness window must be greater than zero" + ); + assert_eq!( + too_long.to_string(), + "revocation material window is 301 seconds, exceeding the local maximum of 300 seconds" + ); + assert_eq!( + future.to_string(), + "revocation material is not usable at trusted time 999; thisUpdate is 1000" + ); + assert_eq!( + stale.to_string(), + "revocation material is stale at trusted time 1100; nextUpdate is 1100" + ); + + for error in [invalid, zero_maximum, too_long, future, stale] { + assert!(error.source().is_none()); + } +} diff --git a/docs/README.md b/docs/README.md index 03b573c54..1ea57ad29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ - [OriginWeave API and protocol contract](API_CONTRACT.md) - [Release and rollback contract](RELEASE_AND_ROLLBACK.md) - [Product roadmap](product-roadmap.md) +- [Product and technical gap baseline](product-technical-gap-baseline.md) - [Research and standards](doctoring.md) - [Browser and Agent protocol standards evidence](doctoring/browser-agent-protocols.md) - [Current product-baseline standards addendum](doctoring/product-documentation-baseline.md) @@ -86,4 +87,12 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. +### Proposed decisions introduced by active feature work + +- [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) + +ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. diff --git a/docs/adr/0016-bap-task-lifecycle-authority.md b/docs/adr/0016-bap-task-lifecycle-authority.md new file mode 100644 index 000000000..54fae8607 --- /dev/null +++ b/docs/adr/0016-bap-task-lifecycle-authority.md @@ -0,0 +1,123 @@ +# ADR 0016: BAP task lifecycle and state authority + +- **Status:** Proposed +- **Date:** 2026-08-22 +- **Supersedes:** None +- **Superseded by:** None + +## Context + +OriginWeave needs a deterministic lifecycle primitive for governed browser-agent work before durable BAP transport, persistence, idempotency, or crash recovery can be added safely. A task state is security-relevant because downstream components may use it to decide whether work may start, resume, complete, reconcile, or terminate. If adapters, persistence layers, browser drivers, or recovery code can mint state independently, OriginWeave would inherit ambient execution authority from whichever boundary supplied the most convenient state value. + +The `originweave-bap` crate therefore introduces a typed in-memory state machine with monotonic transition receipts and fail-closed recovery validation. The crate deliberately owns no browser, network, model, secret, approval, persistence, tenant-authentication, or protocol authority. External protocols may project lifecycle intent into this kernel, but protocol metadata cannot bypass its transition rules or upgrade a task's authority. + +## Decision drivers + +- Keep task-state authority explicit and deterministic rather than distributed across protocol adapters. +- Prevent stale, unreachable, or terminal lifecycle snapshots from reopening governed work. +- Preserve a monotonic transition sequence suitable for later durable replay evidence without claiming persistence today. +- Separate lifecycle state from browser, network, secret, model, approval, and tenant authority. +- Make waiting, checkpoint, reconciliation, completion, cancellation, expiry, and dead-letter behavior typed and testable. +- Keep recovery validation fail closed when a supplied state/sequence pair cannot arise from the reviewed state machine. + +## Assumptions and authority boundaries + +- The lifecycle is an in-memory logical primitive; it is not a durable task repository. +- Creating or restoring a lifecycle does not authenticate a caller, tenant, browser session, document, origin, destination, secret, model, approval, or external side effect. +- A transition receipt proves only what this in-memory lifecycle instance accepted. It is not durable audit evidence until a separate authenticated persistence boundary stores it. +- Waiting for approval is a lifecycle condition, not proof that approval exists. A later approval authority must independently authenticate and authorize any decision before resumption. +- `Succeeded` is entered only after a caller asserts that its separately governed post-condition has been verified; the lifecycle does not itself verify that post-condition. +- Reconciliation and dead-letter states preserve control-flow intent only. Durable reconciliation evidence remains the responsibility of a later persistence/recovery boundary. + +## Options considered + +### Let each BAP or MCP adapter own its own state machine + +Rejected. Adapter-local state machines would duplicate policy, make recovery semantics drift by protocol, and allow external protocol metadata to become implicit OriginWeave execution authority. + +### Store task state as an unrestricted string or integer + +Rejected. Untyped state admits unknown values, weakens exhaustive transition review, and makes invalid or stale recovery snapshots difficult to reject deterministically. + +### Allow restored state to resume whenever the state name looks resumable + +Rejected. State-only recovery loses monotonic history. A state/sequence pair that cannot be reached through the reviewed transitions must fail closed rather than becoming execution authority. + +### Centralize logical lifecycle transitions in a typed Rust kernel + +Selected. + +## Decision + +If Accepted, OriginWeave applies these lifecycle rules: + +1. **One typed kernel owns logical BAP task state.** `originweave-bap` is the canonical state-transition authority for the task lifecycle represented by this contract. Protocol adapters may request transitions but do not mint lifecycle state directly. +2. **Transitions are explicit and fail closed.** The kernel accepts only reviewed event/state combinations. Invalid events preserve the existing state and sequence and return a typed error. +3. **Terminal states never reopen.** `Succeeded`, `Failed`, `Cancelled`, `Expired`, and `DeadLettered` reject later lifecycle events. +4. **Waiting and checkpoint states require explicit resumption.** Approval wait, external-input wait, and checkpoint states do not silently become running work. +5. **Reconciliation is distinct from normal suspension.** A task in `ReconciliationRequired` cannot use the ordinary resume path; it requires explicit reconciliation resolution or governed dead-letter handling. +6. **Transition sequence is monotonic and bounded.** Every accepted transition advances the sequence exactly once. Sequence exhaustion fails closed instead of wrapping. +7. **Recovery validates reachability.** A supplied state/sequence snapshot must be reachable under the same reviewed state machine. Unreachable snapshots are rejected with a typed restore error. +8. **Lifecycle state grants no ambient authority.** A `Running`, resumable, or otherwise valid lifecycle state does not authorize browser I/O, network destinations, secret resolution, model access, approvals, external protocol operations, or tenant access. Those authorities must be revalidated by their owning boundaries. +9. **Durability is a separate owner.** This contract does not claim atomic persistence, idempotency, locking, authenticated replay evidence, side-effect reconciliation, or crash-safe recovery. Later durable components must bind those concerns to lifecycle receipts without weakening this state authority. +10. **External protocol state is projected, not inherited.** BAP, MCP, WebDriver BiDi, CDP, or other adapters may translate reviewed external events into typed lifecycle requests only after their own authentication and policy checks. External state labels cannot overwrite the kernel directly. + +## Consequences + +OriginWeave gains one reviewable state authority that later transport, idempotency, persistence, and recovery slices can compose without duplicating transition semantics. Invalid transitions and unreachable recovery snapshots have deterministic typed failures, while terminal and reconciliation states have explicit closure behavior. + +The trade-off is that adapters and durable stores must perform explicit mapping and validation instead of assigning state directly. The current slice also cannot claim commercial crash recovery until durable authenticated evidence and side-effect reconciliation are implemented separately. + +## Failure and degraded behavior + +- An invalid event returns a typed transition error and leaves state/history unchanged. +- A terminal lifecycle rejects all later events rather than reopening work. +- Sequence exhaustion returns a typed failure rather than wrapping or silently reusing an identifier. +- An unreachable restored state/sequence pair is rejected rather than normalized into a nearby valid state. +- Missing browser, tenant, policy, destination, secret, approval, persistence, or recovery authority is not converted into lifecycle success. +- If a future adapter cannot map external protocol state without ambiguity, it must fail closed or require reconciliation rather than inventing a lifecycle transition. + +## Security / privacy / governance impact + +This decision narrows authority. It prevents external protocol metadata, stale snapshots, or arbitrary state assignment from becoming execution authority and keeps lifecycle state separate from sensitive-data, secret, browser, network, model, approval, and tenant boundaries. The lifecycle stores no secret values or personal-data payloads by itself. Any future persistent representation must independently satisfy OriginWeave data-governance, retention, tenant-isolation, integrity, and evidence requirements. + +## Tests and acceptance evidence + +The owning branch must keep executable evidence for: + +- the reviewed created/admitted/running/waiting/checkpointed/reconciliation/terminal transition paths; +- fail-closed invalid transitions with no sequence advancement; +- terminal irreversibility; +- cancellation and expiry across allowed pre-dispatch and suspended states; +- explicit reconciliation resolution and governed dead-letter behavior; +- monotonic transition receipts and sequence-exhaustion failure; +- recovery acceptance for reachable snapshots and rejection for unreachable snapshots; and +- deterministic public Rust error contracts. + +Repository contracts must also require this ADR so the `originweave-bap` control-plane boundary cannot remain undocumented while the crate is present. Exact protected-main acceptance still depends on current-head CI, exact owned-production coverage, rustdoc, security evidence, review, live governance, and integration state; ADR presence does not substitute for those gates. + +## Migration and rollback + +No database migration is introduced. Existing callers on this branch construct the typed lifecycle directly. A future durable task repository should persist state and transition evidence in an authenticated form that can be validated by this kernel rather than introducing a second transition authority. + +Rollback before acceptance is removal of the active BAP lifecycle branch and its Proposed ADR. After acceptance, rollback or replacement must preserve fail-closed terminal/recovery semantics or explicitly supersede this ADR with a reviewed migration for any persisted lifecycle representation. + +## Open follow-ups + +- Bind durable idempotency receipts to exact accepted transitions without making retry metadata task authority. +- Define authenticated persistence, atomicity, and concurrency semantics for lifecycle plus command evidence. +- Define crash-recovery classification and reconciliation for ambiguous external side effects. +- Map authenticated BAP/MCP transport messages into typed lifecycle requests without ambient protocol authority. +- Propagate cancellation and expiry into real browser/process supervision only after the corresponding runtime authority exists. + +## Supersession / reversal conditions + +Supersede this ADR if OriginWeave replaces the BAP lifecycle model, introduces a materially different durable event-sourced task authority, or moves canonical task-state ownership to another reviewed component. A successor must preserve explicit state authority, terminal fail-closure, monotonic recovery evidence, and the rule that lifecycle state cannot mint unrelated browser/network/secret/model/approval/tenant authority. + +## References + +ContextualWisdomLab. (2026). *OriginWeave architecture* [Repository specification]. *OriginWeave*. [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) + +ContextualWisdomLab. (2026). *OriginWeave architecture decision records* [Repository specification]. *OriginWeave*. [`README.md`](README.md) + +ContextualWisdomLab. (2026). *Agent development contract* [Repository specification]. *OriginWeave*. [`../../AGENTS.md`](../../AGENTS.md) diff --git a/docs/adr/0106-provenance-evidence-model.md b/docs/adr/0106-provenance-evidence-model.md index 0e2741f37..09cb0d7ca 100644 --- a/docs/adr/0106-provenance-evidence-model.md +++ b/docs/adr/0106-provenance-evidence-model.md @@ -33,29 +33,47 @@ OriginWeave maintains provenance-native evidence with stable identifiers for ses WARC and PROV are interoperability/export contracts, not substitutes for OriginWeave's internal authorization or evidence schema. A WARC record can contain untrusted or sensitive payload bytes and therefore inherits capture, retention, encryption, and export policy. A PROV entity/activity/agent relation records derivation or responsibility; it cannot manufacture authentication, authorization, durable completion, or tenant ownership not established by the producing system. +### Versioned extraction-schema binding + +A versioned `ExtractionSchema` is the binding contract for typed extraction before any capture persistence or export format is allowed to claim semantic authority. Each schema version contains an ordered, non-empty set of unique `ExtractionField` definitions. Schema-version and field identifiers are bounded to 128 encoded bytes, begin with a lowercase ASCII letter, and thereafter admit only lowercase ASCII letters, digits, `_`, or `-`. One schema admits at most 256 fields. + +Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. Cardinality and required status form one internally consistent presence contract: `One` is necessarily required, `ZeroOrOne` is necessarily optional, and `Many` may be marked required or optional because this value-object layer does not yet define a minimum collection item count. Contradictory `One`/optional or `ZeroOrOne`/required declarations fail closed during field construction. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. + +At this value-object boundary, the version identifier is immutable schema identity; there is deliberately no registry that silently treats two different field contracts as compatible merely because their version strings compare or sort in a particular way. Callers changing a field identifier, value type, cardinality, required status, normalization rule, or admitted source-channel set must use a distinct reviewed schema version and perform any migration/compatibility decision at an explicit higher layer. The current schema object does not itself read browser data, materialize extracted values, persist artifacts, execute models, or change governance policy. Those capabilities require separately authorized runtime boundaries and are not implied by schema construction. + ## Consequences Capture becomes a designed product surface rather than incidental logging. Storage and retention need budgets. Consumers can distinguish a model claim from source evidence and an action request from verified completion. Export adapters can target WARC, provenance graphs, audit streams, or buyer-specific schemas. +A schema consumer can also determine the exact field/type/cardinality/normalization/source contract it reviewed rather than relying on free-form extraction instructions. Schema evolution is explicit instead of being inferred from mutable field definitions; runtime compatibility, migrations, durable storage, and extracted-value validation remain separate implementation work until those boundaries are delivered. + ## Failure and degraded behavior If mandatory evidence cannot be recorded durably enough for a governed state-changing action, the action fails before execution or reports an explicit unverifiable failure; it is never marked proved. Read-only operations may degrade to reduced evidence only when the API contract declares that mode. Corrupt or incomplete evidence is quarantined rather than silently accepted. +Invalid or oversized extraction identifiers, contradictory cardinality/required declarations, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. + ## Security / privacy / governance impact Evidence is tenant-scoped, selectively disclosed, encrypted as appropriate, retention-bounded, and auditable. Credential-bearing headers, cookies, secret values, and sensitive form data are excluded or transformed according to explicit schema policy. Integrity metadata and immutable artifact identities support tamper detection without claiming external certification. `docs/DATA_GOVERNANCE.md` defines the disclosure/retention boundary for protected content and derived artifacts. +The extraction-schema contract does not modify governance authority. It describes admissible typed fields and reviewed evidence-channel classes only. In particular, declaring `NetworkResponse` or `ModelInterpretation` does not authorize network access, model execution, protected-data disclosure, approvals, retention, or export; those remain governed by their existing owning boundaries. + ## Tests and acceptance evidence Require provenance-link tests, credential-leak tests, integrity/corruption tests, crash-recovery tests, WARC/export conformance where implemented, PROV relation/schema tests where implemented, retention/deletion tests, tenant-isolation tests, and end-to-end checks that state-changing actions link request, policy, approval, execution, and post-condition as separate records. Export tests must prove that disabled or unauthorized source bodies never appear merely because metadata provenance is exportable. +The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, consistent cardinality/required combinations and contradictory-combination rejection, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. + ## Migration and rollback Introduce stable evidence identifiers and schema versions before changing export formats. Migrations preserve old evidence semantics or explicitly mark unavailable fields. Rollback may revert an exporter but cannot collapse mandatory action and policy evidence into opaque logs. +Extraction contract changes that alter field identity or semantics require a new reviewed schema version rather than mutating the meaning of an existing version. Rolling back a consumer may stop accepting a newer version, but it must not reinterpret that newer contract as an older one or silently discard required fields. + ## Open follow-ups -Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. +Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. Add the runtime that validates concrete extracted values against an `ExtractionSchema`, plus explicit migration/compatibility policy when durable schema registration is introduced. ## Supersession / reversal conditions diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index 8923616be..fb1bf2e17 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -34,6 +34,16 @@ OriginWeave exposes its own versioned protocol for session, observation, query, MCP version negotiation is independent of the OriginWeave Protocol version. As of this review, MCP `2026-07-28` is the current released protocol generation; a future MCP change does not silently alter OriginWeave task, approval, secret, tenant, or browser semantics. MCP tool/resource content remains untrusted input and any server-to-client/user interaction capability is mediated by the same OriginWeave policy/approval boundaries as other adapter traffic. +### Current implementation boundary + +The complete MCP adapter remains **Planned**. Protected main now contains the narrower bounded Rust `tools/call` routing/action-policy foundation merged through PR #168. That protected-main foundation validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. + +Active PR #170 is a separate non-shipped refinement on top of that protected-main catalog. It adds one conservative typed `tools/list` request/result contract: both protocol-version fields are required and bounded before comparison, client-capability metadata must be present without becoming authority, both routing/body methods are syntax-bounded before correlation, only exact `tools/list` is admitted, and every caller-supplied cursor is rejected because the current fixed catalog issues none. The result is one complete page with zero freshness, private cache scope, and no continuation cursor. + +Neither protected main nor PR #170 implements Streamable HTTP transport parsing, JSON-RPC/HTTP serialization, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, general pagination/subscription state, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` may therefore describe only the bounded merged `tools/call` foundation as implemented; the full MCP adapter remains planned, and the `tools/list` refinement remains active-PR evidence until separately integrated. + +The version boundary is explicit: the protected-main routing foundation and active discovery refinement accept only MCP `2026-07-28`; neither infers compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. + ## Consequences OriginWeave carries adapter maintenance and version negotiation but gains a durable customer API. Multiple browser/control transports can coexist. New upstream capabilities do not silently change risk or action semantics. Compatibility matrices become release artifacts. @@ -44,19 +54,21 @@ Adapter negotiation failure disables only affected capabilities. Unsupported or ## Security / privacy / governance impact -Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. +Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Method and tool routing metadata is shape-bounded before correlation, preventing malformed or oversized untrusted routing strings from being reinterpreted through mismatch handling. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. ## Tests and acceptance evidence Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. +For the protected-main `tools/call` foundation, acceptance includes deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and integrated review evidence from PR #168. For active PR #170, exact-current acceptance additionally requires bounded protocol metadata before cross-field comparison, required client-capabilities presence, bounded `tools/list` method correlation, rejection of unissued cursors, deterministic result/cache semantics, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. + ## Migration and rollback Adapters are independently versioned and can be canaried. Clients migrate through OriginWeave Protocol compatibility rules, not upstream protocol rewrites. Rollback pins a previously supported adapter/browser/protocol pair and records that pair in provenance. ## Open follow-ups -Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, and MCP/WebMCP schema isolation. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP transport serialization, authenticated deployment, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions @@ -68,10 +80,12 @@ Chrome DevTools Protocol. (2026). *Chrome DevTools Protocol — latest (tip-of-t Chrome DevTools Protocol. (2026). *WebMCP domain*. Chromium. Retrieved August 9, 2026, from https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + Parra, D. S., & Delimarsky, D. (2026, July 28). *The 2026-07-28 specification*. Model Context Protocol Blog. https://blog.modelcontextprotocol.io/posts/2026-07-28/ World Wide Web Consortium. (2026, June 29). *WebDriver BiDi* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260629/ ## Related documents -See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring/product-documentation-baseline.md`, and `docs/DATA_GOVERNANCE.md`. +See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring.md`, `docs/doctoring/product-documentation-baseline.md`, `docs/traceability/README.md`, and `docs/DATA_GOVERNANCE.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 416231b1c..5f9e2a878 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,16 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +### Proposed decisions introduced by active feature work + +| ADR | Decision | Status | Governs | +|---|---|---|---| +| [0016](0016-bap-task-lifecycle-authority.md) | BAP task lifecycle and state authority | Proposed | BAP task states, transitions, recovery validation, transition sequencing, and authority separation | + +ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and active-PR, non-protected-main maturity. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. ## Index completeness rule diff --git a/docs/doctoring/rust-toolchain-freshness.md b/docs/doctoring/rust-toolchain-freshness.md new file mode 100644 index 000000000..a00e7fb08 --- /dev/null +++ b/docs/doctoring/rust-toolchain-freshness.md @@ -0,0 +1,44 @@ +# Rust toolchain freshness and reproducibility + +## Decision + +OriginWeave keeps Rust `1.97.1` as the exact stable compiler baseline. As of +2026-08-19 this is the current stable point release, so the generic compiler +suggestion to upgrade does not justify replacing it with a floating `stable` +channel. + +Production line, region, and function coverage remains on the stable compiler. +Branch coverage uses the independently date-pinned `nightly-2026-08-18` +toolchain because upstream `cargo-llvm-cov` still identifies Rust branch +coverage as unstable and nightly-only. Every branch-coverage command must use +the same date pin, and exact-head CI must prove that `llvm-tools-preview`, the +pinned `cargo-llvm-cov` release, the workspace, and the coverage verifier remain +compatible before merge. + +The root `rust-toolchain.toml` is tracked through GitHub Dependabot's +`rust-toolchain` ecosystem. Toolchain changes therefore arrive as reviewable +pull requests rather than silently changing underneath local or CI builds. +Date-pinned branch-coverage nightly updates remain explicit infrastructure +changes and must preserve the repository contract test. + +## Failure interpretation + +The historical OriginWeave coverage failure at PR #192 predecessor head +`ccb7d31dfe7654bab800d463c2391cc1a19c7d74` was not proof that the compiler was +too old. The compiler emitted the generic note while rejecting a non-stable +const conversion in test code. The current PR #192 head moved that conversion +out of a constant and passed the complete native CI workflow. Toolchain +freshness and source compatibility are therefore maintained as separate +controls. + +## References + +GitHub. (2025, August 19). *Dependabot now supports Rust toolchain updates*. +GitHub Changelog. +https://github.blog/changelog/2025-08-19-dependabot-now-supports-rust-toolchain-updates/ + +Rust Project Developers. (2026, July 16). *Announcing Rust 1.97.1*. Rust Blog. +https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ + +Taiki Endo and contributors. (2026). *cargo-llvm-cov* (Version 0.8.6) +[Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 000000000..c867d25a2 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,103 @@ +# Product and Technical Gap Baseline + +This file is the current delivery baseline for OriginWeave. It records buyer-visible gaps and exact repository evidence; it does not replace the PRD, TRD, architecture, ADRs, threat model, test strategy, or live GitHub state. Protected `main` is the shipped implementation boundary. Open PRs, successful predecessor checks, synthetic mergeability, and command acknowledgements are not shipped behavior. + +## Observed snapshot: 2026-09-06 + +### Protected-main truth + +- Protected `main` is exact `87c4daa1830bac5a5228b6036752ad5633232085`. GitHub reports the commit signature as verified/valid. +- The repository currently has **125 open pull requests: 12 non-draft and 113 draft**. +- The repository currently has **13 open non-PR issues**. +- The GitHub Releases API currently returns an empty collection: **0 GitHub Releases**. No release-ready claim is valid until a protected exact head is integrated and an immutable release artifact, SBOM, provenance, rollback evidence, tag/package, and release are all verified. +- Protected-main code and tests remain authority for shipped behavior. A feature branch can be useful evidence without being a production capability. + +### Foundation and stack integrity + +The active WebDriver BiDi stack inherited historical whole-tree replacement `5c111d0db6c363f9d1786c21cc01c5c7398007bd` (`fix(stack): restore opening-write prerequisite tree`). It restored its transport prerequisite while also removing unrelated valid product/source/test/documentation assets. That deletion is a repair finding rather than grounds to close dependent PRs. + +PR #195 is the earliest active owner point for the foundation recovery. The branch remains based on retained prerequisite #193 `6922dd98779e8f8aad132a3b1f563d7ba6e6d070`. Its recovery lineage includes: + +- `29dd314501299a3ad8276e5d73189591ff6327a0`, which restored the BAP workspace member, MCP and release-acceptance contracts/tests, destination freshness/revalidation, policy MCP binding, resource error contracts, TLS revocation/trust, the Agent Task fixture, and this product/technical gap baseline without replacing the modular WebDriver BiDi core; +- `89708cf5e474f7701513b84a1356a8ce1699bef5`, which restored extraction schema, sensitive-handle lifecycle, RFC 3986 evidence-path admission, and their tests while retaining `BrowserProtocolValidationEvidence` and its browser-protocol regression; and +- later content-aware documentation recovery through `64114aab9e000f9cdc017f68e6c926e5abf28df3`, restoring architecture/index/ADR discoverability, product authority contracts, and the protected MCP product boundary without copying a whole protected tree over later WebDriver work. + +The baseline deliberately does not embed PR #195's mutable live head as its own current identity. Any commit that updates this file would immediately make such a literal stale. Evidence commands therefore re-resolve the PR head from GitHub before fetching checks. `tests/test_gap_snapshot_inventory_consistency.py` enforces that rule instead of pinning a self-invalidating head SHA. + +Exact predecessor `7b2f30941ba2a2b17870f31a835a3ed0217be17c` exercised the next recovery RED. CI `34010603105` failed in Python repository contracts while Production coverage succeeded; MV3 `34010603003` also succeeded. Three leaf/content defects were repaired without changing workflow source: `6e5aeb583738aa3b3c3433d7f63265fac8aef8c3` restores protected `.github/dependabot.yml`, `c8f850707474726eb580ec2652c254e79d37bb9b` aligns the Proposed ADR lifecycle assertion with the canonical bold metadata, and `7dbdf0364768f049286bc5cb59e85e9978d533ca` restores the required fail-closed release wording. The remaining two REDs are both workflow-contract mismatches: this historical branch still carries an older CI concurrency/lifecycle generation and `nightly-2026-08-01`, while protected main carries repository-scoped concurrency, PR-only cancellation, explicit Draft/closed lifecycle guards, and `nightly-2026-08-18`. Issue #279 now contains the exact owner-path evidence. Scheduled product ownership does not weaken those tests or edit `.github/workflows/**` to make the stale generation pass. + +PR #242 still targets the pre-recovery #195 generation `48eb2d23009c1c804520dd5efcd0d4d072aacef1` and GitHub currently reports it non-mergeable. Descendants must adopt the repaired foundation content-aware and non-destructively; preserving an old child tree in a topology-only merge would reintroduce the deleted product assets. Each reconstructed exact head needs fresh checks. No predecessor GREEN transfers. + +### Browser sandbox and realistic Chromium acceptance + +PR #148 is exact `0135984f1bc1f68d89d7777f49c4999474105a12`. Its repository CI `33990522263` is terminal success with exact 100% reported production coverage (415 functions, 3,555 lines, 4,444 regions, 476 branches). Its real Manifest V3 Compatibility run `33990522248`, job `101371812631`, is terminal failure on pinned Chrome `150.0.7871.129` after all inherited `--no-sandbox` launch overrides were removed. Artifact `9977680352` reports 0/3 for ordinary MV3, ordinary Agent Task, forced-close Agent Task, and browser-crash Agent Task; the crash lane localizes to `failure_stage=session_create`, `failure_type=RuntimeError`, `reason_code=runtime_error`. Cleanup completion is not browser success. + +Issue #212 is the canonical workflow-owner boundary for the missing sandbox-helper integration. PR #43 previously proved that root-owned mode-`4755` `chrome_sandbox` plus `CHROME_DEVEL_SANDBOX` can run the same Chrome generation sandboxed on that leaf generation, but its GREEN does not transfer to #148 or the current protected workflow. The authorized owner must reconstruct the validated helper mechanics against the current protected MV3 workflow, preserve harden-runner/egress, immutable pins, Draft/closed lifecycle and evidence retention, then consumers must adopt it non-destructively and regenerate exact-head Linux browser evidence. Restoring `--no-sandbox`, reducing trials, or treating cleanup as success is not an acceptable repair. + +### WebDriver BiDi navigation stack + +The repaired teardown/navigation chain #255 → #256 → #257 → #258 → #259 → #260 → #261 → #277 has terminal repository-native success on the already-restacked exact heads. That evidence validates those exact trees only; it does not cure foundation lineage, transfer central review/security evidence, or establish real-browser acceptance. + +PR #263 adds a typed `session.unsubscribe` path for the exact opaque committed-navigation subscription receipt. Predecessor `37ae698c4a9e12d2fabf821ae5b910ea8a35ab8a` failed hosted CI because canonical rustfmt was not applied and one real `send()` frame-error arm was uncovered. Repair `3f22de94b63da83eaa8b5b1270912b21a3ecd006` applies canonical formatting and adds the realistic loopback RFC 6455 no-write `MalformedFrame` path caused by adjacent client masking-key reuse, proving no unsubscribe bytes reach the peer and only that unsubscribe correlation retires. Exact CI `34009256997` is terminal success: Rust contracts job `101422055630` passed Python contracts, formatting, locked workspace checks, tests, Clippy and rustdoc; Production coverage job `101422055538` passed exact coverage enforcement. This GREEN applies only to that exact tree and does not cure the separate foundation or browser-runtime prerequisites. + +### CI, review, and evidence control plane + +Issue #279 remains the protected-main owner for exact-head documentation verification, Ready-transition execution, and the protected workflow generation that historical product stacks must preserve rather than weakening repository contracts. The repeated CodeQL dispatch-to-verdict defect observed after successful current-head central scan dispatch is owned by `ContextualWisdomLab/.github#712`; leaf branches must not duplicate CodeQL, weaken required checks, or convert queued/skipped/provider-incomplete evidence into GREEN. + +Protected review/ruleset requirements remain independent from tests. Passing automation is not approval. Stale review state after a push is not current approval, and a Draft, conflicted, or stack-incomplete PR is not merge-ready merely because one repository workflow passed. + +### Product and buyer gaps that remain open + +| Track | Current boundary | Completion evidence required | +|---|---|---| +| Governed browser vertical slice | WebDriver BiDi contracts and transport work are active; current stack requires foundation repair/restack | Real pinned Chromium session/navigation/semantic observation/policy-authorized interaction/post-condition/evidence/cleanup GREEN on the same exact head, then protected integration | +| Chromium sandbox | #148 fails closed at session creation without sandbox bypass; #212 owns workflow integration | Current-generation least-privilege helper adoption plus exact-head sandboxed Linux replay | +| Evidence/provenance | Redacted network/provenance, extraction schema, sensitive lifecycle, and browser-protocol validation contracts exist | Durable replay/retention/deletion and buyer-facing evidence lifecycle proven end-to-end | +| MCP/agent boundary | Typed stateless MCP/core authority contracts exist; MCP remains an adapter | Released API/adapter behavior that cannot become policy authority or bypass browser post-condition verification | +| Persistent task/API surface | Foundations exist | Tenant-scoped persistence, recovery, idempotency, operability, and API acceptance on protected code | +| Enterprise administration | Governance primitives exist | Buyer-visible policy/approval/audit administration with purpose-bound sensitive-data handling and accessibility verification | +| Distribution and release | No GitHub Release exists | signed cross-platform artifacts, SBOM/provenance, reproducibility, rollback, package/tag and immutable release verification | +| CI evidence throughput | Exact-head verification exists but central verdict/queue issues and stale inherited workflow generations remain | Reliable exact-head required workflows without gate weakening, skipped-result promotion, stale workflow adoption, or stale evidence transfer | + +### Bounded-context and ownership constraints + +OriginWeave owns governed browser-domain truth: Browser Session, Navigation, Observation, Interaction Policy integration, Evidence, Extension/native-host boundary, and browser adapters. WebDriver BiDi, CDP and MCP are adapters, not policy authority. Wardnet, EgressWeave, Keyverse, contextual-orchestrator and Context Fabric remain canonical owners of their own domains; OriginWeave consumes only released/versioned contracts or ACLs and must not copy their source, use cross-service SQL, or depend on mutable sibling heads. + +Deterministic browser policy/security decisions remain deterministic. Model-backed workflows must not substitute LLM judgement for browser authority. Command ACK is never sufficient for task success; the expected post-condition and evidence must be observed. + +### Current repair order + +1. Resolve PR #195's live head immediately before interpreting its CI/MV3 results. Preserve the three completed content repairs and have the authorized workflow owner adopt the protected current CI generation rather than weakening the restored repository contracts. +2. Reconcile any remaining inherited documentation differences content-aware; do not overwrite later WebDriver deltas with an older whole tree. +3. Reconstruct #242 and descendants from the repaired #195 foundation using ordinary forward/non-force adoption, then regenerate exact-head checks on every claimed integration point. +4. Complete #212's authorized current-generation Chromium sandbox-helper integration and rerun realistic pinned-Chromium evidence on the exact consumer head. +5. Resolve central required-verdict failures through their canonical owner (`ContextualWisdomLab/.github#712`) rather than leaf duplication or gate weakening. +6. Integrate dependency-first through normal protected-branch review/ruleset gates. +7. Produce and verify the first immutable OriginWeave release with signed artifacts, SBOM, provenance, reproducibility and rollback evidence. + +## Evidence commands + +The snapshot is reproducible from GitHub without treating local branch state as authority. Paginate list endpoints before deriving counts or per-PR evidence. Mutable PR heads are resolved immediately before their evidence is queried; this avoids making the baseline stale merely by committing an update to the baseline itself. + +```bash +set -euo pipefail +repo=ContextualWisdomLab/OriginWeave + +gh api "repos/$repo/branches/main" +gh api --paginate "repos/$repo/pulls?state=open&per_page=100" --slurp +gh api --paginate "repos/$repo/issues?state=open&per_page=100" --slurp +gh api "repos/$repo/releases?per_page=100" + +foundation_head="$(gh api "repos/$repo/pulls/195" --jq ".head.sha")" +gh api "repos/$repo/pulls/195" +gh api "repos/$repo/commits/$foundation_head/check-runs?per_page=100" +gh api "repos/$repo/actions/runs?head_sha=$foundation_head&per_page=100" + +gh api "repos/$repo/pulls/242" +gh api "repos/$repo/pulls/263" +gh api "repos/$repo/pulls/148" +gh api "repos/$repo/issues/212" +gh api "repos/$repo/issues/279" +``` + +Re-fetch the head and base immediately before any merge/readiness decision. If either moved, previous check/review evidence becomes lineage only until the new exact head is verified. diff --git a/docs/traceability/extension-authority-security.md b/docs/traceability/extension-authority-security.md index a36380a31..1c211f83d 100644 --- a/docs/traceability/extension-authority-security.md +++ b/docs/traceability/extension-authority-security.md @@ -50,6 +50,12 @@ Exact head `e83749acd1cf5a0b778ba38eb9d6ed5a9bd1e68f` deliberately keeps only th The exact head has successful CI, exact owned production coverage, Security Scan, SAST and CodeRabbit status and is Ready for review. It has no raw secret bytes and does not create approval evidence, a broker, browser-fill adapter, protected-value store, KMS path, authenticated workload identity, persistence owner, or release claim. +### Origin-bound extension grant evaluation + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` + +The current origin-binding slice requires `ExtensionAgentGrant` and `ExtensionAccessRequest` to carry the same canonical origin. A same-session, same-context request for `https://other.example` or `https://app.example:8443` against a grant for `https://app.example` is `DenyOriginMismatch`. Exclusive trusted-time expiry is evaluated after that origin match: `now >= expires_at` is `DenyExpired`. This does not install an extension, parse Chrome messages, bind task identity, or mint Agent capabilities from Manifest V3 permissions. + ## 4. Security interpretation The executable authority chain is intentionally non-transitive: diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md new file mode 100644 index 000000000..94f181ed4 --- /dev/null +++ b/docs/traceability/mcp-authority-route.md @@ -0,0 +1,58 @@ +# MCP 2026-07-28 authority-route traceability + +- **`tools/call` capability maturity:** `IMPLEMENTED_ON_PROTECTED_MAIN` +- **`tools/list` capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- **Protected-main owning work:** merged PR #168 `feat(mcp): bind stateless tool routing to typed actions` +- **Active follow-on:** PR #170 `feat(mcp): expose conservative tools list cache contract` +- **Complete MCP adapter status:** `PLANNED` +- **Governing decision:** ADR 0107 + +## Scope + +Protected main at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` contains the bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing that merged through PR #168. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. + +A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. + +Active PR #170 builds on that protected-main catalog with a conservative typed `tools/list` request/result boundary. Its current branch requires matching MCP protocol metadata, required client-capability presence, bounded and syntax-validated routing/body methods, exact `tools/list` routing, and no caller-supplied cursor because the fixed catalog issues none. Its result is one complete page with zero freshness, private cache scope, and no continuation cursor. This active-PR slice remains non-shipped until it reaches protected main and does not grant any OriginWeave action authority. + +## Product-status reconciliation + +`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by the bounded `tools/call` foundation now on protected main or by active PR #170: both are reusable control-plane contracts below the complete product adapter. `README.md` and `CHANGELOG.md` distinguish protected-main routing from the active discovery refinement, and ADR 0107 records the protocol/version and authority boundary. + +The following remain outside protected main and PR #170 and must not be inferred from either: + +- Streamable HTTP transport parsing and header materialization; +- JSON-RPC/HTTP response serialization of the typed discovery page; +- OAuth and authenticated MCP deployment policy; +- browser-control I/O or BiDi/CDP/WebMCP translation; +- secret materialization or broker transport; +- persistence, durable audit storage, or WARC/PROV export; +- general pagination/subscription state beyond the fixed no-cursor catalog; and +- an OriginWeave Protocol version transition. + +## Version boundary + +The protected-main routing foundation and active discovery refinement accept only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. + +The reviewed primary source is: + +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + +The canonical bibliography remains `docs/doctoring.md`. + +## Executable evidence + +Protected-main PR #168 production/test surfaces include: + +- `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog plus method/tool routing validation in the `ValidatedMcpToolCall` primitive; +- `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, exact method/tool bounds, empty/oversized/malformed inputs, version/method/header-body correlation, and error-contract evidence; +- `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and +- `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. + +Active PR #170 additionally exercises its discovery contract in `crates/originweave-core/tests/mcp_tools_list_cache.rs`, including result/cache semantics, required protocol/client metadata, bounded protocol and method validation, routing correlation, cursor rejection, and public error contracts. + +Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Protected-main evidence proves only the merged `tools/call` foundation; predecessor or protected-main results are not current-head proof for active PR #170. + +## Promotion rule + +The bounded `tools/call` routing foundation is already `IMPLEMENTED_ON_PROTECTED_MAIN`. The `tools/list` discovery refinement may change to `IMPLEMENTED_ON_PROTECTED_MAIN` only after PR #170 reaches protected `main` under live governance and exact-head acceptance. Neither promotion makes the complete MCP adapter implemented; each remaining transport/runtime boundary requires its own integrated evidence. diff --git a/tests/fixtures/agent_task_basic/index.html b/tests/fixtures/agent_task_basic/index.html new file mode 100644 index 000000000..510b239f1 --- /dev/null +++ b/tests/fixtures/agent_task_basic/index.html @@ -0,0 +1,42 @@ + + + + + + OriginWeave controlled Agent Task fixture + + +
+

Controlled Agent Task

+

This page is synthetic test data for deterministic browser integration.

+ +
+ + + +
+ + idle + + +
+ + + + diff --git a/tests/test_agent_task_fixture_contract.py b/tests/test_agent_task_fixture_contract.py new file mode 100644 index 000000000..2565a35c7 --- /dev/null +++ b/tests/test_agent_task_fixture_contract.py @@ -0,0 +1,137 @@ +"""Fail-first contract for the controlled Chromium Agent Task fixture.""" + +from __future__ import annotations + +from html.parser import HTMLParser +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" + + +def _is_credential_input(attributes: dict[str, str | None]) -> bool: + """Return whether parsed input attributes describe a credential surface.""" + + input_type = (attributes.get("type") or "").strip().lower() + if input_type == "password": + return True + + autocomplete = (attributes.get("autocomplete") or "").strip().lower() + autocomplete_tokens = autocomplete.split() + return any( + token == "one-time-code" or "password" in token + for token in autocomplete_tokens + ) + + +class _FixtureParser(HTMLParser): + """Collect the small semantic surface required by the deterministic fixture.""" + + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.labels_for: set[str] = set() + self.input_names: set[str] = set() + self.input_attributes: list[dict[str, str | None]] = [] + self.button_types: set[str] = set() + self.hidden_injection_markers = 0 + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + attributes = dict(attrs) + element_id = attributes.get("id") + if element_id: + self.ids.add(element_id) + if tag == "label" and attributes.get("for"): + self.labels_for.add(attributes["for"]) + if tag == "input": + self.input_attributes.append(attributes) + if attributes.get("name"): + self.input_names.add(attributes["name"]) + if tag == "button" and attributes.get("type"): + self.button_types.add(attributes["type"]) + if ( + attributes.get("data-originweave-untrusted") == "prompt-injection" + and "hidden" in attributes + and attributes.get("aria-hidden") == "true" + ): + self.hidden_injection_markers += 1 + + +class AgentTaskFixtureContractTests(unittest.TestCase): + """Require one deterministic semantic workflow for the first browser slice.""" + + def setUp(self) -> None: + """Load the checked-in fixture once for each independent contract.""" + + self.html = FIXTURE.read_text(encoding="utf-8") + self.parser = _FixtureParser() + self.parser.feed(self.html) + + def test_fixture_exposes_semantic_form_and_observable_post_condition(self) -> None: + """The fixture must support role/name discovery and a deterministic state change.""" + + self.assertIn("task-text", self.parser.ids) + self.assertIn("task-text", self.parser.labels_for) + self.assertIn("task_text", self.parser.input_names) + self.assertIn("submit", self.parser.button_types) + self.assertIn("task-result", self.parser.ids) + self.assertIn('data-state="idle"', self.html) + self.assertIn('result.dataset.state = "submitted"', self.html) + self.assertIn("result.textContent = taskText.value", self.html) + + def test_fixture_contains_explicit_untrusted_hidden_prompt_injection(self) -> None: + """A later real-browser regression needs hostile hidden page content to ignore.""" + + self.assertEqual(self.parser.hidden_injection_markers, 1) + self.assertIn("UNTRUSTED_PAGE_INSTRUCTION", self.html) + self.assertIn("request new browser capabilities", self.html) + + def test_hidden_injection_requires_the_actual_hidden_attribute(self) -> None: + """ARIA metadata alone must not satisfy the hidden-injection fixture contract.""" + + parser = _FixtureParser() + parser.feed( + "" + "" + ) + self.assertEqual(parser.hidden_injection_markers, 1) + + def test_fixture_is_synthetic_and_has_no_credential_fields(self) -> None: + """The controlled workflow must not require or imitate real secret collection.""" + + for attributes in self.parser.input_attributes: + with self.subTest(attributes=attributes): + self.assertFalse(_is_credential_input(attributes)) + + lowered = self.html.lower() + for forbidden in ("api_key", "secret_key"): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, lowered) + + def test_credential_detection_is_quote_independent(self) -> None: + """Parsed credential semantics must reject single-quoted and tokenized forms.""" + + for html in ( + "", + "", + "", + "", + "", + ): + with self.subTest(html=html): + parser = _FixtureParser() + parser.feed(html) + self.assertEqual(len(parser.input_attributes), 1) + self.assertTrue(_is_credential_input(parser.input_attributes[0])) + + parser = _FixtureParser() + parser.feed("") + self.assertEqual(len(parser.input_attributes), 1) + self.assertFalse(_is_credential_input(parser.input_attributes[0])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_doctoring_reference_contract.py b/tests/test_doctoring_reference_contract.py new file mode 100644 index 000000000..bdeded44f --- /dev/null +++ b/tests/test_doctoring_reference_contract.py @@ -0,0 +1,28 @@ +"""Regression contracts for standards references that bind OriginWeave design claims.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring.md" + + +class DoctoringReferenceContractTests(unittest.TestCase): + """Keep cited primary-standard authorship aligned with the canonical source.""" + + def test_rfc_5280_reference_uses_canonical_author_initials(self) -> None: + """RFC 5280 must credit Sharon Boeyen as S. Boeyen, matching RFC Editor metadata.""" + text = DOCTORING.read_text(encoding="utf-8") + expected = ( + "Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. " + "(2008). *Internet X.509 public key infrastructure certificate and certificate " + "revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. " + "https://doi.org/10.17487/RFC5280" + ) + self.assertIn(expected, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py new file mode 100644 index 000000000..305674f99 --- /dev/null +++ b/tests/test_gap_snapshot_inventory_consistency.py @@ -0,0 +1,51 @@ +"""Regression contracts for the current product-gap inventory snapshot.""" + +from __future__ import annotations + +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" + + +class GapSnapshotInventoryConsistencyTests(unittest.TestCase): + """Prevent the canonical snapshot from carrying contradictory live PR totals.""" + + @classmethod + def setUpClass(cls) -> None: + cls.baseline = BASELINE.read_text(encoding="utf-8") + + def test_current_inventory_is_internally_consistent(self) -> None: + """Ready plus Draft counts must equal the recorded open-PR total.""" + match = re.search( + r"\*\*(\d+) open pull requests: (\d+) non-draft and (\d+) draft\*\*", + self.baseline, + ) + self.assertIsNotNone(match) + total, non_draft, draft = (int(value) for value in match.groups()) + self.assertEqual((total, non_draft, draft), (125, 12, 113)) + self.assertEqual(non_draft + draft, total) + + def test_current_snapshot_has_one_protected_main_identity(self) -> None: + """The delivery boundary must name the current signed protected head.""" + current = self.baseline.split("## Observed snapshot: 2026-09-06", 1)[1] + self.assertIn("87c4daa1830bac5a5228b6036752ad5633232085", current) + self.assertNotIn("b05d5acca82b9d916ada2c8e82f59f92a89817e1", current) + + def test_evidence_procedure_re_resolves_mutable_pr_head(self) -> None: + """Evidence commands must resolve the live PR head instead of freezing a self-stale SHA.""" + self.assertIn( + "Re-fetch the head and base immediately before any merge/readiness decision", + self.baseline, + ) + self.assertIn("--paginate", self.baseline) + self.assertIn('foundation_head="$(gh api "repos/$repo/pulls/195" --jq ".head.sha")"', self.baseline) + self.assertIn('commits/$foundation_head/check-runs', self.baseline) + self.assertIn('head_sha=$foundation_head', self.baseline) + self.assertNotIn("head_sha=89708cf5e474f7701513b84a1356a8ce1699bef5", self.baseline) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py new file mode 100644 index 000000000..4f42cf311 --- /dev/null +++ b/tests/test_product_completion_gap_contract.py @@ -0,0 +1,73 @@ +"""Regression contract for the code-current commercial gap baseline.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" + + +class ProductCompletionGapContractTests(unittest.TestCase): + """Keep buyer gaps tied to the current repository snapshot and authority model.""" + + @classmethod + def setUpClass(cls) -> None: + cls.text = BASELINE.read_text(encoding="utf-8") + + def test_baseline_records_current_inventory_and_protected_head(self) -> None: + """The current snapshot must not retain the superseded August inventory as current.""" + for phrase in ( + "## Observed snapshot: 2026-09-06", + "87c4daa1830bac5a5228b6036752ad5633232085", + "125 open pull requests", + "12 non-draft", + "113 draft", + "13 open non-PR issues", + "0 GitHub Releases", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, self.text) + + for stale_phrase in ( + "126 open pull requests", + "54 non-draft and 72 draft", + "Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1`", + ): + with self.subTest(stale_phrase=stale_phrase): + self.assertNotIn(stale_phrase, self.text) + + def test_current_blockers_and_owner_paths_are_explicit(self) -> None: + """Commercial completion must retain the exercised browser and control-plane gaps.""" + for phrase in ( + "89708cf5e474f7701513b84a1356a8ce1699bef5", + "#242", + "0135984f1bc1f68d89d7777f49c4999474105a12", + "failure_stage=session_create", + "Issue #212", + "Issue #279", + "ContextualWisdomLab/.github#712", + "No predecessor GREEN transfers", + "Command ACK is never sufficient", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, self.text) + + def test_release_and_dependency_boundaries_remain_fail_closed(self) -> None: + """The baseline must require an immutable release and versioned owner contracts.""" + for phrase in ( + "signed cross-platform artifacts", + "SBOM/provenance", + "reproducibility", + "rollback", + "released/versioned contracts or ACLs", + "cross-service SQL", + "mutable sibling heads", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, self.text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_gap_discoverability_contract.py b/tests/test_product_gap_discoverability_contract.py new file mode 100644 index 000000000..237420e1c --- /dev/null +++ b/tests/test_product_gap_discoverability_contract.py @@ -0,0 +1,35 @@ +"""Regression contracts for discoverability of the commercial gap baseline.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" + + +class ProductGapDiscoverabilityContractTests(unittest.TestCase): + """Keep the code-current buyer gap baseline reachable from canonical indexes.""" + + def test_gap_baseline_exists_and_is_linked_from_canonical_indexes(self) -> None: + """Architecture and documentation readers must not reconstruct the baseline from PR history.""" + self.assertTrue(BASELINE.is_file()) + architecture = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") + documentation_index = (ROOT / "docs" / "README.md").read_text(encoding="utf-8") + self.assertIn("docs/product-technical-gap-baseline.md", architecture) + self.assertIn("product-technical-gap-baseline.md", documentation_index) + + def test_bap_lifecycle_adr_is_indexed_without_premature_acceptance(self) -> None: + """Restoring the ADR file must restore discoverability without changing its lifecycle.""" + adr = (ROOT / "docs" / "adr" / "0016-bap-task-lifecycle-authority.md").read_text( + encoding="utf-8" + ) + index = (ROOT / "docs" / "adr" / "README.md").read_text(encoding="utf-8") + self.assertIn("- **Status:** Proposed", adr) + self.assertIn("0016-bap-task-lifecycle-authority.md", index) + self.assertIn("| Proposed |", index) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..00ceb5a12 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,6 +20,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination", "crates/originweave-network", @@ -59,6 +60,7 @@ def test_required_architecture_and_governance_documents_exist(self) -> None: "docs/adr/0005-direct-socket-binding.md", "docs/adr/0006-tls-server-identity.md", "docs/adr/0009-hourly-agent-credential-boundary.md", + "docs/adr/0016-bap-task-lifecycle-authority.md", "docs/superpowers/specs/2026-08-06-resolved-destination-policy-design.md", "docs/superpowers/specs/2026-08-06-direct-socket-binding-design.md", "docs/superpowers/specs/2026-08-06-tls-server-identity-design.md", @@ -118,6 +120,15 @@ def test_ci_validates_the_exact_pull_request_head(self) -> None: self.assertIn(f"exact-coverage-{exact_head}", workflow) self.assertIn("permissions:\n contents: read", workflow) self.assertNotIn("contents: write", workflow) + self.assertIn("${{ github.workflow }}-${{ github.repository }}", workflow) + self.assertIn("${{ github.event.pull_request.number || github.run_id }}", workflow) + self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow) + self.assertIn( + "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]", + workflow, + ) + self.assertEqual(workflow.count("github.event.pull_request.draft == false"), 2) + self.assertNotIn("cargo check --locked --workspace --all-targets", workflow) def test_hourly_loop_uses_nvidia_nim_and_dedicated_publication_authority(self) -> None: """The product loop must use OpenCode/NIM without review or merge credentials.""" @@ -185,4 +196,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_rust_toolchain_contract.py b/tests/test_rust_toolchain_contract.py new file mode 100644 index 000000000..85d83044d --- /dev/null +++ b/tests/test_rust_toolchain_contract.py @@ -0,0 +1,43 @@ +"""Regression contracts for the reproducible Rust compiler baseline.""" + +from __future__ import annotations + +import tomllib +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" +CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" +HOURLY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "hourly-product-development.yml" +DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" + + +class RustToolchainContractTests(unittest.TestCase): + """Keep stable builds reproducible and branch coverage intentionally fresh.""" + + def test_stable_toolchain_is_exact_and_automatically_tracked(self) -> None: + """The stable compiler changes only through a reviewable manifest update.""" + + manifest = tomllib.loads(RUST_TOOLCHAIN.read_text(encoding="utf-8")) + self.assertEqual(manifest["toolchain"]["channel"], "1.97.1") + + dependabot = DEPENDABOT.read_text(encoding="utf-8") + self.assertIn('package-ecosystem: "rust-toolchain"', dependabot) + self.assertIn('directory: "/"', dependabot) + self.assertIn('interval: "weekly"', dependabot) + + def test_branch_coverage_uses_one_current_date_pinned_nightly(self) -> None: + """Every branch-coverage command uses the same reviewed nightly snapshot.""" + + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(workflow.count("nightly-2026-08-18"), 3) + self.assertNotIn("nightly-2026-08-01", workflow) + + hourly_workflow = HOURLY_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(hourly_workflow.count("nightly-2026-08-18"), 2) + self.assertNotIn("nightly-2026-08-01", hourly_workflow) + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py b/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py new file mode 100644 index 000000000..2847b278d --- /dev/null +++ b/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py @@ -0,0 +1,26 @@ +from pathlib import Path +import unittest + + +SOURCE = Path("crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs") + + +class WebDriverBiDiOpeningWriteTimeoutCleanupContract(unittest.TestCase): + def test_successful_opening_write_clears_operation_local_socket_timeout(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + + self.assertIn("fn clear_write_timeout(&self) -> io::Result<()>;", source) + self.assertIn("TcpStream::set_write_timeout(self, None)", source) + + helper_start = source.index("fn write_request_with_clock(") + helper_end = source.index("\n#[cfg(test)]", helper_start) + helper = source[helper_start:helper_end] + clear_call = helper.rfind("writer.clear_write_timeout()") + success = helper.rfind("Ok(bytes_written)") + + self.assertGreater(clear_call, -1) + self.assertGreater(success, clear_call) + + +if __name__ == "__main__": + unittest.main()