From e690f9da8f3244a1ff96b854856bf2feb85e1b44 Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 13 Jul 2026 16:41:56 -0400 Subject: [PATCH 1/2] feat: add fail-closed OpenMed privacy gate --- Cargo.lock | 33 + Cargo.toml | 4 + README.md | 4 + crates/helix-openmed/Cargo.toml | 17 + crates/helix-openmed/src/lib.rs | 852 ++++++++++++++++++ crates/helix-wasm/Cargo.toml | 1 + crates/helix-wasm/src/lib.rs | 32 + docs/COVERAGE.md | 1 + ...penmed-local-clinical-text-privacy-gate.md | 96 ++ docs/adr/README.md | 1 + docs/openmed-integration.md | 76 ++ ui/openmed-adapter.js | 184 ++++ ui/openmed-adapter.test.mjs | 131 +++ ui/package.json | 8 + 14 files changed, 1440 insertions(+) create mode 100644 crates/helix-openmed/Cargo.toml create mode 100644 crates/helix-openmed/src/lib.rs create mode 100644 docs/adr/ADR-050-openmed-local-clinical-text-privacy-gate.md create mode 100644 docs/openmed-integration.md create mode 100644 ui/openmed-adapter.js create mode 100644 ui/openmed-adapter.test.mjs create mode 100644 ui/package.json diff --git a/Cargo.lock b/Cargo.lock index 42da6ef..bc4e50a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -655,6 +655,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "helix-openmed" +version = "0.1.2" +dependencies = [ + "hmac", + "regex", + "serde", + "serde_json", + "sha2", + "thiserror", +] + [[package]] name = "helix-pipeline" version = "0.1.2" @@ -791,6 +803,7 @@ dependencies = [ "helix-numeric", "helix-ocr", "helix-ontology", + "helix-openmed", "helix-pipeline", "helix-provenance", "helix-refranges", @@ -810,6 +823,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1418,6 +1440,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2cc6867..8d1f3a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/helix-timeline", "crates/helix-evolve", "crates/helix-refranges", + "crates/helix-openmed", "crates/helix-demo", "crates/helix-wasm", ] @@ -45,6 +46,9 @@ serde_json = "1" thiserror = "2" criterion = { version = "0.5", default-features = false } proptest = "1" +sha2 = "0.10" +hmac = "0.12" +regex = "1" [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 0ac77cd..58b763e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ This is no longer just a spec — the testable core is implemented in Rust and v pharmacogenomics (`helix-genome`/rvDNA), OCR ingestion (`helix-ocr`), semantic retrieval (`helix-retrieval`), visual RAG (`helix-visual`/rupixel), privacy-preserving cohort (`helix-cohort`), FHIR connectors (`helix-connect`), and federation transport (`helix-fed`). +- **Local clinical-text privacy**: OpenMed 1.9 ONNX candidate detection is wrapped by the + fail-closed `helix-openmed` Rust/WASM gate with artifact pinning, complete-document + coverage, deterministic identifier rules, Unicode-safe offsets, and HMAC-only receipts. + See the [integration guide](docs/openmed-integration.md). - **Web console + WASM mobile app** running the real pipeline in-browser — **[live demo](https://ruvnet.github.io/helix/)**. Every integration keeps the discipline: the LLM narrates (never reasons), embeddings/visual diff --git a/crates/helix-openmed/Cargo.toml b/crates/helix-openmed/Cargo.toml new file mode 100644 index 0000000..6489db4 --- /dev/null +++ b/crates/helix-openmed/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "helix-openmed" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Fail-closed OpenMed clinical-text privacy and de-identification gate for Helix" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +sha2.workspace = true +hmac.workspace = true +regex.workspace = true diff --git a/crates/helix-openmed/src/lib.rs b/crates/helix-openmed/src/lib.rs new file mode 100644 index 0000000..9d6827e --- /dev/null +++ b/crates/helix-openmed/src/lib.rs @@ -0,0 +1,852 @@ +//! OpenMed integration boundary for clinical text. +//! +//! OpenMed is a probabilistic candidate-span producer. Helix remains the +//! policy authority: it pins model artifacts, proves complete document +//! coverage, normalizes offsets, adds deterministic identifier findings and +//! fails closed before text can leave the local trust boundary. + +use hmac::{Hmac, Mac}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use thiserror::Error; + +type HmacSha256 = Hmac; + +pub const POLICY_VERSION: &str = "helix-openmed-v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OffsetUnit { + Utf8Bytes, + UnicodeScalars, + Utf16CodeUnits, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OpenMedSpan { + pub start: usize, + pub end: usize, + #[serde(default = "default_offset_unit")] + pub offset_unit: OffsetUnit, + pub entity_type: String, + #[serde(default)] + pub canonical_label: Option, + #[serde(default)] + pub policy_label: Option, + pub score: Option, + #[serde(default = "default_detector")] + pub detector: String, +} + +fn default_offset_unit() -> OffsetUnit { + OffsetUnit::Utf8Bytes +} + +fn default_detector() -> String { + "openmed".to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactDigest { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactLock { + pub model_id: String, + /// Immutable source revision. Branch names and moving tags are rejected. + pub revision: String, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoverageWindow { + pub start_byte: usize, + pub end_byte: usize, + pub ordinal: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InferenceCoverage { + pub text_utf8_len: usize, + pub windows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GatePolicy { + #[serde(default = "default_threshold")] + pub minimum_score: f64, + #[serde(default = "default_true")] + pub redact_quasi_identifiers: bool, + #[serde(default = "default_true")] + pub block_unknown_labels: bool, +} + +fn default_threshold() -> f64 { + 0.5 +} + +fn default_true() -> bool { + true +} + +impl Default for GatePolicy { + fn default() -> Self { + Self { + minimum_score: default_threshold(), + redact_quasi_identifiers: true, + block_unknown_labels: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GateRequest { + pub text: String, + pub spans: Vec, + pub coverage: InferenceCoverage, + pub artifact_lock: ArtifactLock, + /// Digests observed by the loader before model execution. + pub loaded_artifacts: Vec, + #[serde(default)] + pub policy: GatePolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DataClass { + ClinicalConcept, + QuasiIdentifier, + DirectIdentifier, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ClinicalSpanRecord { + pub start_byte: usize, + pub end_byte: usize, + pub entity_type: String, + pub class: DataClass, + pub score: f64, + pub detector: String, + /// Vault-scoped HMAC of the source slice. Never a portable plain hash. + pub source_hmac: String, + pub action: SpanAction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpanAction { + KeepLocal, + Redact, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReleaseReceipt { + pub policy_version: String, + pub model_id: String, + pub model_revision: String, + pub artifact_lock_sha256: String, + pub source_document_hmac: String, + pub findings: Vec, + pub covered_utf8_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum GateOutcome { + Approved { + redacted_text: String, + receipt: ReleaseReceipt, + }, + Blocked { + code: String, + reason: String, + }, +} + +#[derive(Debug, Error, PartialEq)] +pub enum GateError { + #[error("HMAC key must contain at least 32 bytes")] + WeakHmacKey, + #[error("invalid model artifact lock: {0}")] + InvalidArtifactLock(String), + #[error("loaded model artifacts do not match the lock")] + ArtifactMismatch, + #[error("inference coverage is incomplete: {0}")] + IncompleteCoverage(String), + #[error("invalid OpenMed span: {0}")] + InvalidSpan(String), + #[error("policy is invalid: {0}")] + InvalidPolicy(String), + #[error("unknown OpenMed policy label: {0}")] + UnknownLabel(String), +} + +/// Create overlapping Unicode-safe windows. Returned boundaries are UTF-8 byte +/// offsets so downstream coverage checks and redaction use one canonical unit. +pub fn plan_windows( + text: &str, + max_scalars: usize, + overlap_scalars: usize, +) -> Result { + if max_scalars == 0 || overlap_scalars >= max_scalars { + return Err(GateError::IncompleteCoverage( + "max_scalars must be positive and overlap must be smaller".into(), + )); + } + if text.is_empty() { + return Ok(InferenceCoverage { + text_utf8_len: 0, + windows: Vec::new(), + }); + } + let mut boundaries: Vec = text.char_indices().map(|(i, _)| i).collect(); + boundaries.push(text.len()); + let scalar_len = boundaries.len() - 1; + let mut windows = Vec::new(); + let mut start = 0usize; + while start < scalar_len { + let end = (start + max_scalars).min(scalar_len); + windows.push(CoverageWindow { + start_byte: boundaries[start], + end_byte: boundaries[end], + ordinal: windows.len(), + }); + if end == scalar_len { + break; + } + start = end - overlap_scalars; + } + Ok(InferenceCoverage { + text_utf8_len: text.len(), + windows, + }) +} + +pub fn validate_artifacts(lock: &ArtifactLock, loaded: &[ArtifactDigest]) -> Result<(), GateError> { + if lock.model_id.trim().is_empty() { + return Err(GateError::InvalidArtifactLock("empty model id".into())); + } + let revision = lock.revision.trim(); + if revision.len() < 12 + || !revision.bytes().all(|b| b.is_ascii_hexdigit()) + || ["main", "master", "latest", "snapshot"] + .iter() + .any(|moving| revision.eq_ignore_ascii_case(moving)) + { + return Err(GateError::InvalidArtifactLock( + "revision must be an immutable hexadecimal commit or content id".into(), + )); + } + if lock.files.is_empty() { + return Err(GateError::InvalidArtifactLock("no artifact files".into())); + } + let expected = digest_map(&lock.files)?; + let actual = digest_map(loaded)?; + if !expected.keys().any(|p| p.ends_with(".onnx")) { + return Err(GateError::InvalidArtifactLock( + "at least one ONNX graph must be locked".into(), + )); + } + if expected != actual { + return Err(GateError::ArtifactMismatch); + } + Ok(()) +} + +fn digest_map(files: &[ArtifactDigest]) -> Result, GateError> { + let mut out = BTreeMap::new(); + for file in files { + if file.path.is_empty() + || file.path.starts_with('/') + || file.path.contains(['\\', ':', '?', '#']) + || file.path.split('/').any(|part| part == "..") + { + return Err(GateError::InvalidArtifactLock(format!( + "unsafe artifact path: {}", + file.path + ))); + } + if file.sha256.len() != 64 || !file.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(GateError::InvalidArtifactLock(format!( + "invalid SHA-256 for {}", + file.path + ))); + } + if out + .insert(file.path.as_str(), file.sha256.to_ascii_lowercase()) + .is_some() + { + return Err(GateError::InvalidArtifactLock(format!( + "duplicate artifact path: {}", + file.path + ))); + } + } + Ok(out) +} + +pub fn gate_document(request: &GateRequest, hmac_key: &[u8]) -> Result { + if hmac_key.len() < 32 { + return Err(GateError::WeakHmacKey); + } + if !request.policy.minimum_score.is_finite() + || !(0.0..=1.0).contains(&request.policy.minimum_score) + { + return Err(GateError::InvalidPolicy( + "minimum_score must be finite and in 0..=1".into(), + )); + } + validate_artifacts(&request.artifact_lock, &request.loaded_artifacts)?; + validate_coverage(&request.text, &request.coverage)?; + + let mut findings = normalize_openmed_spans(&request.text, &request.spans, &request.policy)?; + findings.extend(detect_direct_identifiers(&request.text)?); + findings = merge_findings(findings); + + if request.policy.block_unknown_labels { + // Unknown labels are rejected during normalization. This branch makes + // the fail-closed policy explicit in the serialized configuration. + } + + let mut records = Vec::with_capacity(findings.len()); + for finding in &findings { + let action = if finding.class == DataClass::DirectIdentifier + || (finding.class == DataClass::QuasiIdentifier + && request.policy.redact_quasi_identifiers) + { + SpanAction::Redact + } else { + SpanAction::KeepLocal + }; + records.push(ClinicalSpanRecord { + start_byte: finding.start, + end_byte: finding.end, + entity_type: finding.entity_type.clone(), + class: finding.class, + score: finding.score, + detector: finding.detector.clone(), + source_hmac: keyed_hash( + hmac_key, + &request.text.as_bytes()[finding.start..finding.end], + )?, + action, + }); + } + + let redacted_text = redact(&request.text, &records); + let lock_bytes = serde_json::to_vec(&request.artifact_lock) + .map_err(|e| GateError::InvalidArtifactLock(e.to_string()))?; + let receipt = ReleaseReceipt { + policy_version: POLICY_VERSION.to_string(), + model_id: request.artifact_lock.model_id.clone(), + model_revision: request.artifact_lock.revision.clone(), + artifact_lock_sha256: sha256_hex(&lock_bytes), + source_document_hmac: keyed_hash(hmac_key, request.text.as_bytes())?, + findings: records, + covered_utf8_bytes: request.text.len(), + }; + Ok(GateOutcome::Approved { + redacted_text, + receipt, + }) +} + +/// Converts expected validation failures to a serializable blocked decision. +/// This is the recommended boundary for any egress path. +pub fn release_or_block(request: &GateRequest, hmac_key: &[u8]) -> GateOutcome { + match gate_document(request, hmac_key) { + Ok(outcome) => outcome, + Err(error) => GateOutcome::Blocked { + code: error_code(&error).to_string(), + reason: error.to_string(), + }, + } +} + +fn error_code(error: &GateError) -> &'static str { + match error { + GateError::WeakHmacKey => "weak_hmac_key", + GateError::InvalidArtifactLock(_) => "invalid_artifact_lock", + GateError::ArtifactMismatch => "artifact_mismatch", + GateError::IncompleteCoverage(_) => "incomplete_coverage", + GateError::InvalidSpan(_) => "invalid_span", + GateError::InvalidPolicy(_) => "invalid_policy", + GateError::UnknownLabel(_) => "unknown_label", + } +} + +fn validate_coverage(text: &str, coverage: &InferenceCoverage) -> Result<(), GateError> { + if coverage.text_utf8_len != text.len() { + return Err(GateError::IncompleteCoverage( + "declared text length does not match input".into(), + )); + } + if text.is_empty() { + if coverage.windows.is_empty() { + return Ok(()); + } + return Err(GateError::IncompleteCoverage( + "empty input must have no windows".into(), + )); + } + if coverage.windows.is_empty() { + return Err(GateError::IncompleteCoverage("no inference windows".into())); + } + let mut windows = coverage.windows.clone(); + windows.sort_by_key(|w| (w.start_byte, w.end_byte)); + let mut covered_until = 0usize; + let mut ordinals = BTreeSet::new(); + for window in windows { + if window.start_byte >= window.end_byte + || window.end_byte > text.len() + || !text.is_char_boundary(window.start_byte) + || !text.is_char_boundary(window.end_byte) + || !ordinals.insert(window.ordinal) + { + return Err(GateError::IncompleteCoverage( + "invalid window boundary or duplicate ordinal".into(), + )); + } + if window.start_byte > covered_until { + return Err(GateError::IncompleteCoverage(format!( + "gap begins at byte {covered_until}" + ))); + } + covered_until = covered_until.max(window.end_byte); + } + if covered_until != text.len() { + return Err(GateError::IncompleteCoverage(format!( + "coverage ends at byte {covered_until} of {}", + text.len() + ))); + } + Ok(()) +} + +#[derive(Debug, Clone)] +struct Finding { + start: usize, + end: usize, + entity_type: String, + class: DataClass, + score: f64, + detector: String, +} + +fn normalize_openmed_spans( + text: &str, + spans: &[OpenMedSpan], + policy: &GatePolicy, +) -> Result, GateError> { + let mut out = Vec::new(); + for span in spans { + let score = span + .score + .ok_or_else(|| GateError::InvalidSpan("score is required".into()))?; + if !score.is_finite() || !(0.0..=1.0).contains(&score) { + return Err(GateError::InvalidSpan("score must be in 0..=1".into())); + } + if score < policy.minimum_score { + continue; + } + let start = offset_to_byte(text, span.start, span.offset_unit)?; + let end = offset_to_byte(text, span.end, span.offset_unit)?; + if start >= end { + return Err(GateError::InvalidSpan("start must precede end".into())); + } + let class = classify(span); + let class = match class { + Some(value) => value, + None if policy.block_unknown_labels => { + return Err(GateError::UnknownLabel(span.entity_type.clone())) + } + None => DataClass::DirectIdentifier, + }; + out.push(Finding { + start, + end, + entity_type: span.entity_type.clone(), + class, + score, + detector: span.detector.clone(), + }); + } + Ok(out) +} + +fn classify(span: &OpenMedSpan) -> Option { + let label = span + .policy_label + .as_deref() + .or(span.canonical_label.as_deref()) + .unwrap_or(&span.entity_type) + .to_ascii_lowercase() + .replace(['-', ' '], "_"); + match label.as_str() { + "direct_identifier" + | "name" + | "first_name" + | "middle_name" + | "last_name" + | "person" + | "patient" + | "doctor" + | "username" + | "email" + | "password" + | "phone" + | "fax" + | "ssn" + | "social_security_number" + | "mrn" + | "medical_record_number" + | "account_number" + | "certificate_number" + | "url" + | "ip_address" + | "mac_address" + | "imei" + | "id_num" + | "api_key" + | "pin" + | "credit_card" + | "cvv" + | "iban" + | "vin" + | "device_identifier" + | "biometric_identifier" => Some(DataClass::DirectIdentifier), + "quasi_identifier" | "date" | "date_of_birth" | "time" | "age" | "location" | "address" + | "street_address" | "city" | "state" | "zip" | "zipcode" | "postal_code" + | "organization" | "profession" | "occupation" | "job_title" | "gps_coordinates" => { + Some(DataClass::QuasiIdentifier) + } + "clinical_concept" | "clinical" | "condition" | "diagnosis" | "medication" | "drug" + | "lab" | "observation" | "procedure" | "anatomy" | "symptom" | "dosage" | "strength" + | "frequency" | "duration" => Some(DataClass::ClinicalConcept), + _ => None, + } +} + +fn offset_to_byte(text: &str, offset: usize, unit: OffsetUnit) -> Result { + match unit { + OffsetUnit::Utf8Bytes => { + if offset <= text.len() && text.is_char_boundary(offset) { + Ok(offset) + } else { + Err(GateError::InvalidSpan( + "UTF-8 offset is not a character boundary".into(), + )) + } + } + OffsetUnit::UnicodeScalars => { + if offset == text.chars().count() { + return Ok(text.len()); + } + text.char_indices() + .nth(offset) + .map(|(index, _)| index) + .ok_or_else(|| GateError::InvalidSpan("scalar offset exceeds input".into())) + } + OffsetUnit::Utf16CodeUnits => { + let mut units = 0usize; + for (index, ch) in text.char_indices() { + if units == offset { + return Ok(index); + } + units += ch.len_utf16(); + if units > offset { + return Err(GateError::InvalidSpan( + "UTF-16 offset splits a surrogate pair".into(), + )); + } + } + if units == offset { + Ok(text.len()) + } else { + Err(GateError::InvalidSpan("UTF-16 offset exceeds input".into())) + } + } + } +} + +fn detect_direct_identifiers(text: &str) -> Result, GateError> { + let patterns = [ + ("EMAIL", r"(?i)\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b"), + ("SSN", r"\b\d{3}-\d{2}-\d{4}\b"), + ( + "PHONE", + r"(?x)\b(?:\+?1[ .-]?)?(?:\(?\d{3}\)?[ .-]?)\d{3}[ .-]\d{4}\b", + ), + ("IP_ADDRESS", r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), + ( + "MRN", + r"(?i)\b(?:MRN|medical[ ]record[ ](?:number|no\.?))[ :#-]*[A-Z0-9-]{4,}\b", + ), + ]; + let mut out = Vec::new(); + for (entity_type, pattern) in patterns { + let re = Regex::new(pattern) + .map_err(|error| GateError::InvalidPolicy(format!("detector regex: {error}")))?; + out.extend(re.find_iter(text).map(|hit| Finding { + start: hit.start(), + end: hit.end(), + entity_type: entity_type.to_string(), + class: DataClass::DirectIdentifier, + score: 1.0, + detector: "helix_deterministic_v1".to_string(), + })); + } + Ok(out) +} + +fn merge_findings(mut findings: Vec) -> Vec { + findings.sort_by(|a, b| { + a.start + .cmp(&b.start) + .then_with(|| b.end.cmp(&a.end)) + .then_with(|| b.class.cmp(&a.class)) + }); + let mut merged: Vec = Vec::new(); + for finding in findings { + if let Some(last) = merged.last_mut() { + if finding.start < last.end { + last.end = last.end.max(finding.end); + if finding.class > last.class { + last.class = finding.class; + last.entity_type = finding.entity_type; + last.detector = finding.detector; + } + last.score = last.score.max(finding.score); + continue; + } + } + merged.push(finding); + } + merged +} + +fn redact(text: &str, records: &[ClinicalSpanRecord]) -> String { + let mut output = String::with_capacity(text.len()); + let mut cursor = 0usize; + for record in records { + if record.action != SpanAction::Redact { + continue; + } + output.push_str(&text[cursor..record.start_byte]); + output.push_str("[REDACTED:"); + output.push_str(&record.entity_type.to_ascii_uppercase()); + output.push(']'); + cursor = record.end_byte; + } + output.push_str(&text[cursor..]); + output +} + +fn keyed_hash(key: &[u8], value: &[u8]) -> Result { + let mut mac = HmacSha256::new_from_slice(key).map_err(|_| GateError::WeakHmacKey)?; + mac.update(value); + Ok(hex(&mac.finalize().into_bytes())) +} + +fn sha256_hex(value: &[u8]) -> String { + hex(&Sha256::digest(value)) +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8; 32] = b"helix-test-only-hmac-key-32-byte"; + + fn digest(path: &str, digit: char) -> ArtifactDigest { + ArtifactDigest { + path: path.into(), + sha256: std::iter::repeat(digit).take(64).collect(), + } + } + + fn request(text: &str) -> GateRequest { + let files = vec![digest("model.onnx", 'a'), digest("tokenizer.json", 'b')]; + GateRequest { + text: text.into(), + spans: Vec::new(), + coverage: plan_windows(text, 32, 8).unwrap(), + artifact_lock: ArtifactLock { + model_id: "openmed/clinical-deid".into(), + revision: "4ee5b28d0f118a2a521cb781551c1dcd343f8db2".into(), + files: files.clone(), + }, + loaded_artifacts: files, + policy: GatePolicy::default(), + } + } + + #[test] + fn unicode_windows_cover_every_byte() { + let text = "A🙂é中文 clinical note"; + let coverage = plan_windows(text, 5, 2).unwrap(); + validate_coverage(text, &coverage).unwrap(); + assert!(coverage.windows.len() > 1); + assert_eq!(coverage.windows.last().unwrap().end_byte, text.len()); + } + + #[test] + fn converts_utf16_offsets_without_splitting_surrogates() { + let text = "🙂 Dr. Ada"; + assert_eq!( + offset_to_byte(text, 3, OffsetUnit::Utf16CodeUnits).unwrap(), + 5 + ); + assert!(offset_to_byte(text, 1, OffsetUnit::Utf16CodeUnits).is_err()); + } + + #[test] + fn deterministic_detector_catches_identifier_missed_by_model() { + let text = "Contact ada@example.org about ferritin."; + let output = gate_document(&request(text), KEY).unwrap(); + let GateOutcome::Approved { + redacted_text, + receipt, + } = output + else { + panic!("expected approval") + }; + assert_eq!(redacted_text, "Contact [REDACTED:EMAIL] about ferritin."); + assert_eq!(receipt.findings.len(), 1); + assert!(!serde_json::to_string(&receipt) + .unwrap() + .contains("ada@example.org")); + } + + #[test] + fn clinical_concepts_are_preserved() { + let text = "Patient takes metformin"; + let mut req = request(text); + req.spans.push(OpenMedSpan { + start: 14, + end: 23, + offset_unit: OffsetUnit::Utf8Bytes, + entity_type: "MEDICATION".into(), + canonical_label: None, + policy_label: Some("clinical_concept".into()), + score: Some(0.99), + detector: "openmed".into(), + }); + let GateOutcome::Approved { + redacted_text, + receipt, + } = gate_document(&req, KEY).unwrap() + else { + panic!("expected approval") + }; + assert_eq!(redacted_text, text); + assert_eq!(receipt.findings[0].action, SpanAction::KeepLocal); + } + + #[test] + fn incomplete_coverage_blocks_release() { + let mut req = request("patient@example.org"); + req.coverage.windows[0].start_byte = 1; + let outcome = release_or_block(&req, KEY); + assert!( + matches!(outcome, GateOutcome::Blocked { code, .. } if code == "incomplete_coverage") + ); + } + + #[test] + fn artifact_drift_blocks_release() { + let mut req = request("note"); + req.loaded_artifacts[0].sha256 = "c".repeat(64); + assert!(matches!( + release_or_block(&req, KEY), + GateOutcome::Blocked { code, .. } if code == "artifact_mismatch" + )); + } + + #[test] + fn artifact_paths_cannot_escape_the_model_root() { + let mut req = request("note"); + req.artifact_lock.files[0].path = "../model.onnx".into(); + req.loaded_artifacts[0].path = "../model.onnx".into(); + assert!(matches!( + release_or_block(&req, KEY), + GateOutcome::Blocked { code, .. } if code == "invalid_artifact_lock" + )); + } + + #[test] + fn unknown_labels_block_release() { + let mut req = request("secret"); + req.spans.push(OpenMedSpan { + start: 0, + end: 6, + offset_unit: OffsetUnit::Utf8Bytes, + entity_type: "NEW_UPSTREAM_LABEL".into(), + canonical_label: None, + policy_label: None, + score: Some(0.9), + detector: "openmed".into(), + }); + assert!(matches!( + release_or_block(&req, KEY), + GateOutcome::Blocked { code, .. } if code == "unknown_label" + )); + } + + #[test] + fn overlapping_findings_redact_once() { + let text = "Email ada@example.org now"; + let mut req = request(text); + req.spans.push(OpenMedSpan { + start: 6, + end: 21, + offset_unit: OffsetUnit::Utf8Bytes, + entity_type: "EMAIL".into(), + canonical_label: None, + policy_label: Some("direct_identifier".into()), + score: Some(0.95), + detector: "openmed".into(), + }); + let GateOutcome::Approved { + redacted_text, + receipt, + } = gate_document(&req, KEY).unwrap() + else { + panic!("expected approval") + }; + assert_eq!(redacted_text, "Email [REDACTED:EMAIL] now"); + assert_eq!(receipt.findings.len(), 1); + } + + #[test] + fn synthetic_canary_corpus_has_no_identifier_leakage() { + for n in 0..1_000 { + let email = format!("patient{n}@example.org"); + let text = format!("MRN: HX{n:06}; email {email}; medication metformin"); + let GateOutcome::Approved { + redacted_text, + receipt, + } = gate_document(&request(&text), KEY).unwrap() + else { + panic!("expected approval") + }; + assert!(!redacted_text.contains(&email)); + assert!(!redacted_text.contains(&format!("HX{n:06}"))); + assert!(!serde_json::to_string(&receipt).unwrap().contains(&email)); + } + } +} diff --git a/crates/helix-wasm/Cargo.toml b/crates/helix-wasm/Cargo.toml index 9403e01..daa99fb 100644 --- a/crates/helix-wasm/Cargo.toml +++ b/crates/helix-wasm/Cargo.toml @@ -32,6 +32,7 @@ helix-timeline = { path = "../helix-timeline", version = "0.1.0" } helix-connect = { path = "../helix-connect", version = "0.1.0" } helix-visual = { path = "../helix-visual", version = "0.1.0" } helix-refranges = { path = "../helix-refranges", version = "0.1.0" } +helix-openmed = { path = "../helix-openmed", version = "0.1.0" } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/helix-wasm/src/lib.rs b/crates/helix-wasm/src/lib.rs index aa3d1f8..be348f7 100644 --- a/crates/helix-wasm/src/lib.rs +++ b/crates/helix-wasm/src/lib.rs @@ -224,6 +224,30 @@ pub fn population_range_coverage() -> usize { helix_refranges::coverage() } +/// Plan complete, overlapping, Unicode-safe windows for local OpenMed +/// inference. Boundaries are UTF-8 byte offsets and must be returned with the +/// model spans to [`openmed_gate_json`]. +#[wasm_bindgen] +pub fn openmed_plan_windows_json( + text: &str, + max_scalars: usize, + overlap_scalars: usize, +) -> Result { + let coverage = helix_openmed::plan_windows(text, max_scalars, overlap_scalars).map_err(err)?; + serde_json::to_string(&coverage).map_err(err) +} + +/// Apply the Helix OpenMed privacy gate. The payload is a +/// `helix_openmed::GateRequest`; `hmac_key` must be a fresh vault-scoped secret +/// of at least 32 bytes. Expected validation failures return a serialized +/// `blocked` decision instead of releasing text. +#[wasm_bindgen] +pub fn openmed_gate_json(payload: &str, hmac_key: &[u8]) -> Result { + let request: helix_openmed::GateRequest = serde_json::from_str(payload).map_err(err)?; + let outcome = helix_openmed::release_or_block(&request, hmac_key); + serde_json::to_string(&outcome).map_err(err) +} + /// Import an Apple Health `export.xml` (ADR-029): parse known HealthKit records /// into provenance records. Bounded to 100k records. Returns the records JSON. #[wasm_bindgen] @@ -312,4 +336,12 @@ mod tests { assert!(!version().is_empty()); assert!(redflag_registry_version().starts_with("redflags")); } + + #[test] + fn openmed_window_plan_round_trips_through_json() { + let output = openmed_plan_windows_json("A🙂 clinical note", 5, 2).unwrap(); + let coverage: helix_openmed::InferenceCoverage = serde_json::from_str(&output).unwrap(); + assert_eq!(coverage.text_utf8_len, "A🙂 clinical note".len()); + assert!(coverage.windows.len() > 1); + } } diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md index cea05ce..afc6189 100644 --- a/docs/COVERAGE.md +++ b/docs/COVERAGE.md @@ -50,6 +50,7 @@ remains the specification and the table notes the interface seam. | 028 | Learned visual encoder (local GPU) | ✅ Implemented + GPU-validated (value-guard verified) | `helix-vision` (moondream layout-only + MiniLM; CLIP is the quality upgrade) | | 029 | Live connector clients (FHIR/SMART + wearables) | ✅ Core implemented (sandbox-tested) | `helix-connect` (parse + degradation; real auth pending partner onboarding) | | 030 | Federation transport (opt-in, DP-gated) | ✅ Client implemented | `helix-fed` (only a DP-noised CohortVector can leave; consent required; real network pending) | +| 050 | OpenMed local clinical-text privacy gate | ✅ Implemented | `helix-openmed` + WASM gate + `ui/openmed-adapter.js` | Each integration realizes/strengthens a core ADR: 020→014 (ambient tier backend) + 009 (escalation); 021→005/§7.4 (genome, user-owned); 022→012 (connector degradation, the primary lab path); 023→003/005 diff --git a/docs/adr/ADR-050-openmed-local-clinical-text-privacy-gate.md b/docs/adr/ADR-050-openmed-local-clinical-text-privacy-gate.md new file mode 100644 index 0000000..cf0f61d --- /dev/null +++ b/docs/adr/ADR-050-openmed-local-clinical-text-privacy-gate.md @@ -0,0 +1,96 @@ +# ADR-050: OpenMed Local Clinical-Text Privacy Gate + +**Status**: Accepted +**Date**: 2026-07-13 +**Project**: Helix — Personal Health Intelligence (PHI) +**Related**: ADR-001 (local vault), ADR-004 (ontology normalization), ADR-011 (PII-stripped federation), ADR-013 (on-device inference), ADR-019 (privacy-aware routing), OpenMed PR #1550 + +## Context + +OpenMed PR #1550 adds browser, Node, Python, and Android ONNX token-classification +runtimes plus a common span schema. This gives Helix a useful local clinical-text +candidate detector, but the upstream runtime is not itself an authorization boundary. +Its browser helper can load remote models, its high-level API does not prove that a long +document was processed without truncation, and its output offsets follow runtime-specific +string conventions. Its `deidentify` helper also redacts every detected span, including +clinical concepts that Helix needs for local normalization. + +A model miss is expected behavior for a probabilistic detector. Helix therefore cannot +equate “the model returned no span” with “the text is safe to disclose.” De-identification +reduces privacy risk; it is not, by itself, a determination of HIPAA compliance. + +## Decision + +OpenMed is integrated as an untrusted, probabilistic **candidate-span producer** behind a +Rust policy gate. It never directly authorizes network egress, federation, telemetry, or +logging. + +The `helix-openmed` crate owns these invariants: + +1. Model graphs, tokenizers, external tensor files, and configuration are named in an + artifact lock with SHA-256 digests and an immutable hexadecimal revision. Moving + revisions and digest drift fail closed. +2. Callers use `plan_windows` and return a coverage receipt. The gate verifies contiguous + coverage of every UTF-8 byte. Empty, gapped, truncated, or invalid Unicode windows fail + closed. +3. OpenMed offsets declare their unit and are converted from UTF-16 code units or Unicode + scalar indices to canonical UTF-8 byte boundaries before policy is applied. +4. Helix unions model output with deterministic rules for high-value direct identifiers, + initially email, SSN, phone, IP address, and MRN patterns. +5. Direct identifiers are always redacted. Quasi-identifiers are redacted by default. + Clinical concepts stay local and remain available to the ADR-004 ontology pipeline. + An unknown upstream policy label blocks release by default. +6. Audit receipts contain vault-scoped HMACs, model identity, policy version, byte ranges, + labels, scores, detectors, and actions. They never contain the original span text. +7. Browser execution accepts only same-origin or loopback model URLs, disables remote model + loading, verifies all locked files before model construction, processes every planned + window, and submits candidates to the WASM gate. JavaScript cannot produce an approved + release receipt. + +The WASM surface exposes `openmed_plan_windows_json` and `openmed_gate_json`. A vault-scoped +HMAC key of at least 256 bits is supplied as bytes and is never serialized into the request +or receipt. + +## Data flow + +```mermaid +flowchart TD + A["Local clinical text"] --> B["Pinned OpenMed ONNX runtime"] + B --> C["Candidate spans and coverage"] + A --> D["Helix deterministic detectors"] + C --> E["Rust privacy policy gate"] + D --> E + E -->|approved| F["Redacted text and HMAC receipt"] + E -->|any uncertainty| G["Blocked release"] +``` + +## Alternatives considered + +**Call OpenMed `deidentify` directly.** Rejected because it treats model output as complete, +returns the original input alongside the redacted value, and does not express Helix’s +clinical-concept retention policy. + +**Send text to a hosted de-identification API.** Rejected as the default because it moves +raw clinical text outside the user’s local trust boundary before the privacy decision. + +**Store OpenMed spans as numeric provenance records.** Rejected because character spans are +not measurements. `ClinicalSpanRecord` is a separate audited type; retained concepts enter +ontology normalization through an explicit downstream mapping. + +## Consequences + +OpenMed can improve recall without weakening Helix’s local-first guarantees. Model upgrades +are reproducible and reviewable, Unicode behavior is explicit, and incomplete inference +cannot silently approve egress. The cost is extra local inference for overlapping windows, +artifact hosting, and a deterministic ruleset that must be versioned and expanded as new +identifier classes are evaluated. + +Production deployments must calibrate thresholds and detector recall on representative, +legally governed data. The included synthetic canaries validate software invariants, not +clinical or regulatory fitness. + +## References + +- OpenMed PR #1550, “feat: add local ONNX clinical NLP runtimes and model catalog batch” +- NIST SP 800-107 Rev. 1, keyed hash and hash-function security guidance +- HHS, Guidance Regarding Methods for De-identification of Protected Health Information diff --git a/docs/adr/README.md b/docs/adr/README.md index 386b1cb..3f47319 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,7 @@ The product spec these decisions implement is [`../Helix-PHI-ADR-Product-Spec.md | [034](ADR-034-biological-age-estimate.md) | Biological / Medical Age Estimate from Routine Labs | Dashboard / biomarkers | | [035](ADR-035-darwin-parameter-evolution.md) | Darwin-Style Parameter Evolution (safety-frozen) | Self-optimization / safety | | [036](ADR-036-scale-invariant-trend-band.md) | Scale-Invariant (Reference-Range-Relative) Trend Dead-Band | Accuracy / numerics | +| [050](ADR-050-openmed-local-clinical-text-privacy-gate.md) | OpenMed Local Clinical-Text Privacy Gate | Privacy / on-device NLP | ## Status diff --git a/docs/openmed-integration.md b/docs/openmed-integration.md new file mode 100644 index 0000000..2edf326 --- /dev/null +++ b/docs/openmed-integration.md @@ -0,0 +1,76 @@ +# OpenMed integration + +Helix consumes the browser API introduced by OpenMed PR #1550 without giving +the JavaScript model runtime authority over disclosure. The integration has +three parts: + +- `helix-openmed` is the Rust policy, artifact, coverage, offset, deterministic + detector, redaction, and audit-receipt implementation. +- `helix-wasm` exports Unicode-safe window planning and the final policy gate. +- `ui/openmed-adapter.js` runs the pinned OpenMed 1.9 ONNX pipeline locally and + sends only candidate spans and full coverage evidence into Rust. + +## Model layout + +Bundle a reviewed OpenMed model under the same origin as the application. Do +not use a Hugging Face branch name or a live remote model reference. + +```text +ui/models/openmed/privacy-filter/ +├── config.json +├── model_int8.onnx +├── tokenizer.json +└── tokenizer_config.json +``` + +Create an artifact lock containing the upstream commit or immutable content ID +and the SHA-256 of every file the runtime can load: + +```js +const artifactLock = { + model_id: "OpenMed/privacy-filter-transformersjs", + revision: "", + files: [ + { path: "model_int8.onnx", sha256: "<64 lowercase hex characters>" }, + { path: "tokenizer.json", sha256: "<64 lowercase hex characters>" }, + { path: "config.json", sha256: "<64 lowercase hex characters>" }, + { path: "tokenizer_config.json", sha256: "<64 lowercase hex characters>" }, + ], +}; +``` + +## Browser use + +OpenMed 1.9 is currently represented by the linked upstream pull request. Build +its `js/openmedkit-web` package until that version is published, then pass the +module into the adapter. The Helix UI intentionally does not add an unresolved +npm dependency while the upstream release is pending. + +```js +import * as openmed from "openmed"; +import * as wasm from "./pkg/helix.js"; +import { createOpenMedAdapter } from "./openmed-adapter.js"; + +const adapter = createOpenMedAdapter({ + runtime: openmed, + wasm, + artifactLock, + modelUrl: "/helix/ui/models/openmed/privacy-filter/", + variant: "int8", +}); + +// Derive or unwrap this key from the local vault. Never persist it in source, +// localStorage, telemetry, or a serialized gate request. +const hmacKey = crypto.getRandomValues(new Uint8Array(32)); +const decision = await adapter.deidentify(localClinicalText, hmacKey); + +if (decision.outcome !== "approved") { + throw new Error(`Clinical-text release blocked: ${decision.code}`); +} +sendOnlyAfterApproval(decision.redacted_text, decision.receipt); +``` + +`approved` means that this configured software gate completed. It is not a +HIPAA Safe Harbor or Expert Determination opinion. Threshold selection, model +evaluation, residual-risk assessment, and production disclosure policy remain +clinical, privacy, and legal governance responsibilities. diff --git a/ui/openmed-adapter.js b/ui/openmed-adapter.js new file mode 100644 index 0000000..33104f2 --- /dev/null +++ b/ui/openmed-adapter.js @@ -0,0 +1,184 @@ +// OpenMed browser adapter. Model execution stays local; the Rust/WASM gate is +// the only component allowed to produce releasable text. + +const DEFAULT_WINDOW_SCALARS = 384; +const DEFAULT_OVERLAP_SCALARS = 64; + +/** + * Create a pinned local OpenMed runner. + * + * `runtime` is the API introduced by openmed#1550 and must provide + * `loadOnnxModel` and `extractPii`. The model URL is restricted to same-origin + * or loopback resources to prevent accidental PHI egress and mutable remote + * model loading. + */ +export function createOpenMedAdapter({ + runtime, + wasm, + artifactLock, + modelUrl, + variant = "int8", + maxScalars = DEFAULT_WINDOW_SCALARS, + overlapScalars = DEFAULT_OVERLAP_SCALARS, +}) { + if (!runtime || !wasm?.openmed_plan_windows_json || !wasm?.openmed_gate_json) { + throw new TypeError("OpenMed runtime and Helix WASM gate are required"); + } + assertLocalUrl(modelUrl); + validateLockShape(artifactLock, variant); + + let modelPromise; + async function load() { + if (!modelPromise) { + modelPromise = (async () => { + const loadedArtifacts = await verifyArtifacts(artifactLock, modelUrl); + if (!runtime.loadOnnxModel || !runtime.extractPii) { + throw new TypeError("OpenMed 1.9 loadOnnxModel and extractPii are required"); + } + const pipeline = await runtime.loadOnnxModel(modelUrl, { + variant, + localFilesOnly: true, + allowRemoteModels: false, + revision: artifactLock.revision, + }); + return { pipeline, loadedArtifacts }; + })(); + } + return modelPromise; + } + + return Object.freeze({ + async deidentify(text, hmacKey, policy = {}) { + if (typeof text !== "string") throw new TypeError("text must be a string"); + if (!(hmacKey instanceof Uint8Array) || hmacKey.byteLength < 32) { + throw new TypeError("hmacKey must be a Uint8Array of at least 32 bytes"); + } + const coverage = JSON.parse( + wasm.openmed_plan_windows_json(text, maxScalars, overlapScalars), + ); + const loaded = await load(); + const spans = []; + for (const window of coverage.windows) { + const slice = sliceUtf8Bytes(text, window.start_byte, window.end_byte); + const raw = await runtime.extractPii(slice, { + pipeline: loaded.pipeline, + threshold: policy.minimum_score ?? 0.5, + hashSecret: hmacKey, + detector: "openmed-1.9.0", + }); + spans.push(...normalizeResult(raw, slice, window.start_byte)); + } + const request = { + text, + spans, + coverage, + artifact_lock: artifactLock, + loaded_artifacts: loaded.loadedArtifacts, + policy, + }; + return JSON.parse(wasm.openmed_gate_json(JSON.stringify(request), hmacKey)); + }, + }); +} + +function normalizeResult(result, windowText, windowStartByte) { + const candidates = Array.isArray(result) ? result : result?.spans ?? result?.entities ?? []; + return candidates.map((span) => { + // OpenMed 1.9 uses JavaScript string offsets, which are UTF-16 code units. + const unit = span.offset_unit ?? "utf16_code_units"; + let start = Number(span.start); + let end = Number(span.end); + let offsetUnit = unit; + if (unit === "utf8_bytes") { + start += windowStartByte; + end += windowStartByte; + } else { + // Convert window-local units to global UTF-8 bytes here. This avoids + // ambiguity when a window begins after non-ASCII text. + start = windowStartByte + localOffsetToUtf8(windowText, start, unit); + end = windowStartByte + localOffsetToUtf8(windowText, end, unit); + offsetUnit = "utf8_bytes"; + } + return { + start, + end, + offset_unit: offsetUnit, + entity_type: String(span.entity_type ?? span.entity ?? span.label ?? ""), + canonical_label: span.canonical_label ?? null, + policy_label: span.policy_label ?? null, + score: span.score == null ? null : Number(span.score), + detector: String(span.detector ?? "openmed"), + }; + }); +} + +function localOffsetToUtf8(text, offset, unit) { + if (!Number.isSafeInteger(offset) || offset < 0) throw new TypeError("invalid span offset"); + let prefix; + if (unit === "utf16_code_units") { + prefix = text.slice(0, offset); + if (prefix.length !== offset) throw new RangeError("UTF-16 offset exceeds window"); + } else if (unit === "unicode_scalars") { + prefix = Array.from(text).slice(0, offset).join(""); + if (Array.from(prefix).length !== offset) throw new RangeError("scalar offset exceeds window"); + } else { + throw new TypeError(`unsupported OpenMed offset unit: ${unit}`); + } + return new TextEncoder().encode(prefix).byteLength; +} + +function sliceUtf8Bytes(text, start, end) { + const bytes = new TextEncoder().encode(text).slice(start, end); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); +} + +function assertLocalUrl(value) { + const url = new URL(value, globalThis.location?.href ?? "http://localhost/"); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + const sameOrigin = globalThis.location && url.origin === globalThis.location.origin; + if (!loopback && !sameOrigin) { + throw new TypeError("OpenMed model artifacts must be same-origin or loopback"); + } +} + +function validateLockShape(lock, variant) { + if (!lock?.model_id || !/^[a-f0-9]{12,}$/i.test(lock.revision)) { + throw new TypeError("OpenMed artifact lock requires an immutable revision"); + } + const graph = { int8: "model_int8.onnx", fp32: "model.onnx", fp16: "model_fp16.onnx" }[ + variant + ]; + if (!graph) throw new TypeError("unsupported OpenMed ONNX variant"); + if (!Array.isArray(lock.files) || !lock.files.some((file) => file.path.endsWith(graph))) { + throw new TypeError(`OpenMed artifact lock must include ${graph}`); + } +} + +async function verifyArtifacts(lock, modelUrl) { + const base = new URL(modelUrl, globalThis.location?.href ?? "http://localhost/"); + const observed = []; + for (const file of lock.files) { + if ( + file.path.startsWith("/") || + file.path.split("/").includes("..") || + /[\\:?#]/.test(file.path) + ) { + throw new TypeError("unsafe OpenMed artifact path"); + } + const artifactUrl = new URL(file.path, base); + if (artifactUrl.origin !== base.origin) { + throw new TypeError("OpenMed artifact escaped the local model origin"); + } + const response = await fetch(artifactUrl, { cache: "no-store" }); + if (!response.ok) throw new Error(`OpenMed artifact unavailable: ${file.path}`); + const digest = await crypto.subtle.digest("SHA-256", await response.arrayBuffer()); + const sha256 = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + if (sha256 !== file.sha256.toLowerCase()) { + throw new Error(`OpenMed artifact digest mismatch: ${file.path}`); + } + observed.push({ path: file.path, sha256 }); + } + return observed; +} diff --git a/ui/openmed-adapter.test.mjs b/ui/openmed-adapter.test.mjs new file mode 100644 index 0000000..9ebc857 --- /dev/null +++ b/ui/openmed-adapter.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; + +import { createOpenMedAdapter } from "./openmed-adapter.js"; + +const artifact = new TextEncoder().encode("pinned-model"); +const sha256 = createHash("sha256").update(artifact).digest("hex"); +const lock = { + model_id: "openmed/privacy-filter", + revision: "4ee5b28d0f118a2a521cb781551c1dcd343f8db2", + files: [{ path: "model_int8.onnx", sha256 }], +}; + +test("runs every window through OpenMed then delegates release to WASM", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(artifact); + const calls = []; + let gatedRequest; + const runtime = { + async loadOnnxModel(model, options) { + assert.equal(model, "/models/openmed/"); + assert.equal(options.localFilesOnly, true); + assert.equal(options.allowRemoteModels, false); + return "pipeline"; + }, + async extractPii(text, options) { + calls.push(text); + assert.equal(options.pipeline, "pipeline"); + return [ + { + start: 0, + end: 1, + entity_type: "PERSON", + policy_label: "DIRECT_IDENTIFIER", + canonical_label: "PERSON", + score: 0.99, + }, + ]; + }, + }; + const wasm = { + openmed_plan_windows_json(text) { + return JSON.stringify({ + text_utf8_len: new TextEncoder().encode(text).byteLength, + windows: [ + { start_byte: 0, end_byte: 3, ordinal: 0 }, + { start_byte: 3, end_byte: 6, ordinal: 1 }, + ], + }); + }, + openmed_gate_json(payload, key) { + gatedRequest = JSON.parse(payload); + assert.equal(key.byteLength, 32); + return JSON.stringify({ outcome: "approved", redacted_text: "safe" }); + }, + }; + + try { + const adapter = createOpenMedAdapter({ + runtime, + wasm, + artifactLock: lock, + modelUrl: "/models/openmed/", + }); + const result = await adapter.deidentify("abcdef", new Uint8Array(32).fill(7)); + assert.equal(result.outcome, "approved"); + assert.deepEqual(calls, ["abc", "def"]); + assert.deepEqual( + gatedRequest.spans.map((span) => [span.start, span.end, span.offset_unit]), + [ + [0, 1, "utf8_bytes"], + [3, 4, "utf8_bytes"], + ], + ); + assert.equal(gatedRequest.loaded_artifacts[0].sha256, sha256); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("rejects remote model origins before loading", () => { + assert.throws( + () => + createOpenMedAdapter({ + runtime: {}, + wasm: { openmed_plan_windows_json() {}, openmed_gate_json() {} }, + artifactLock: lock, + modelUrl: "https://models.example.com/openmed/", + }), + /same-origin or loopback/, + ); +}); + +test("rejects model digest drift before inference", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response("different-model"); + let loaded = false; + const adapter = createOpenMedAdapter({ + runtime: { + async loadOnnxModel() { + loaded = true; + }, + async extractPii() { + return []; + }, + }, + wasm: { + openmed_plan_windows_json() { + return JSON.stringify({ + text_utf8_len: 4, + windows: [{ start_byte: 0, end_byte: 4, ordinal: 0 }], + }); + }, + openmed_gate_json() { + throw new Error("must not reach gate"); + }, + }, + artifactLock: lock, + modelUrl: "/models/openmed/", + }); + try { + await assert.rejects( + adapter.deidentify("note", new Uint8Array(32).fill(3)), + /digest mismatch/, + ); + assert.equal(loaded, false); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..3920af8 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,8 @@ +{ + "name": "helix-ui", + "private": true, + "type": "module", + "scripts": { + "test": "node --test openmed-adapter.test.mjs" + } +} From c68655cd0d71640b545b37778bf3dc471125cd11 Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 13 Jul 2026 16:43:26 -0400 Subject: [PATCH 2/2] chore: satisfy current clippy lint --- crates/helix-vault/tests/credentials.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/helix-vault/tests/credentials.rs b/crates/helix-vault/tests/credentials.rs index 0fc7f0a..9d52d67 100644 --- a/crates/helix-vault/tests/credentials.rs +++ b/crates/helix-vault/tests/credentials.rs @@ -65,7 +65,7 @@ fn credentials_round_trip_reject_wrong_pass_and_encrypt_at_rest() { vault.put(c).expect("put credential"); } // Debug must never leak secret field VALUES. - let dbg = format!("{:?}", &creds[0]); + let dbg = format!("{:?}", creds[0]); assert!( !dbg.contains(FAKE_WALGREENS_PASSWORD), "Debug must redact secret field values, got: {dbg}"