diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d0ef113..9c8155383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Runtime browser-adapter version binding at the atomic protocol-use boundary: the caller-supplied bounded adapter-version token must exactly match the reviewed descriptor version before runtime revision or capability checks can succeed, preventing adapter-build drift from silently reusing otherwise matching protocol/browser metadata without authenticating or attesting the adapter process. - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. +- Explicit `BrowserAuthorityRegistry::bind_context_origin` registration that binds one canonical origin to the exact current browser session, browsing context, and document epoch before origin-sensitive protocol use, rejecting cross-session ownership and same-epoch origin changes without granting navigation or action authority. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. @@ -80,4 +81,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index a45da9478..4ec84522b 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -198,6 +198,35 @@ impl BrowserAuthorityRegistry { self.current_epoch(browsing_context) } + /// Bind the canonical origin observed for the exact current browser document. + /// + /// This boundary lets a trusted browser adapter establish current document-origin state before + /// semantic-node discovery begins. The supplied session must own the context. Rebinding the + /// same canonical origin in the same document epoch is idempotent, while a different origin + /// fails closed until [`Self::advance_document`] rotates the document epoch and clears the old + /// binding. The returned epoch is descriptive immediate-use state, not reusable capability. + /// + /// This method does not authenticate the adapter, derive an origin from Chromium, authorize a + /// destination or action, or prove that any browser I/O occurred. + pub fn bind_context_origin( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => {} + None => { + self.context_origin.insert(browsing_context, origin.clone()); + } + } + Ok(epoch) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. /// /// Call this whenever navigation or document replacement invalidates actionable node identity. diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs new file mode 100644 index 000000000..9d404ff3c --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -0,0 +1,91 @@ +use std::error::Error; +use std::io; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, + DocumentEpoch, Origin, +}; + +fn first_origin() -> Result> { + Origin::parse("http://127.0.0.1:43127") + .map_err(|_error| io::Error::other("controlled first origin must be valid").into()) +} + +fn second_origin() -> Result> { + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid").into()) +} + +#[test] +fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = first_origin()?; + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!(epoch, DocumentEpoch::new(1)?); + assert_eq!( + registry.bind_context_origin(session, context, &origin)?, + epoch + ); + + let node = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_eq!(node.document_epoch(), epoch); + assert_eq!(node.origin(), &origin); + Ok(()) +} + +#[test] +fn context_origin_change_requires_document_rotation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.bind_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch, DocumentEpoch::new(2)?); + assert_eq!( + registry.bind_context_origin(session, context, &second)?, + next_epoch + ); + Ok(()) +} + +#[test] +fn context_origin_binding_rejects_cross_session_and_unknown_authority() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + assert_eq!( + registry.bind_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + + let unknown_session = BrowserSessionId::new(999)?; + assert_eq!( + registry.bind_context_origin(unknown_session, context, &origin), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let unknown_context = BrowsingContextId::new(999)?; + assert_eq!( + registry.bind_context_origin(owner, unknown_context, &origin), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..07ddea2d5 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -175,6 +175,12 @@ def test_product_name_is_consistent_in_binding_documents(self) -> None: self.assertNotIn("TraceWeave", text, relative) self.assertNotIn("ProofRail", text, relative) + def test_context_origin_binding_is_recorded_in_the_changelog(self) -> None: + """The public origin-binding boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("BrowserAuthorityRegistry::bind_context_origin", changelog) + def test_database_contract_requires_two_word_snake_case(self) -> None: """Persistent naming policy must include the mandated canonical form.""" @@ -185,4 +191,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()