-
Notifications
You must be signed in to change notification settings - Fork 0
feat(evidence): refuse untrusted payloads without identity and bounds #135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
57cd53c
e20e9b0
1ceea58
e83196f
1576e7f
23789f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| [package] | ||
| name = "payload_bound" | ||
| description = "Untrusted payloads fail closed without identity, provenance, size, and depth." | ||
| version.workspace = true | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| authors.workspace = true | ||
| repository.workspace = true | ||
| homepage.workspace = true | ||
| readme.workspace = true | ||
| keywords.workspace = true | ||
| categories.workspace = true | ||
| publish = false | ||
|
|
||
| [lints] | ||
| workspace = true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| //! Size, depth, identity, and provenance gates for untrusted payloads. | ||
|
|
||
| use crate::PayloadBoundError; | ||
|
|
||
| /// Closed vocabulary of untrusted inbound payload kinds. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| pub enum PayloadKind { | ||
| /// External document bytes. | ||
| Document, | ||
| /// Serialized domain or wire record. | ||
| SerializedRecord, | ||
| /// Model checkpoint or artifact bytes. | ||
| ModelCheckpoint, | ||
| /// LLM or agent output. | ||
| LlmOutput, | ||
| } | ||
|
|
||
| impl PayloadKind { | ||
| /// Return the stable wire payload-kind name. | ||
| #[must_use] | ||
| pub const fn wire_name(self) -> &'static str { | ||
| match self { | ||
| Self::Document => "document", | ||
| Self::SerializedRecord => "serialized_record", | ||
| Self::ModelCheckpoint => "model_checkpoint", | ||
| Self::LlmOutput => "llm_output", | ||
| } | ||
| } | ||
|
|
||
| /// Parse a stable wire payload-kind name. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`PayloadBoundError::InvalidPayloadDecision`] for unrecognized | ||
| /// names. | ||
| pub fn from_wire_name(name: &str) -> Result<Self, PayloadBoundError> { | ||
| match name { | ||
| "document" => Ok(Self::Document), | ||
| "serialized_record" => Ok(Self::SerializedRecord), | ||
| "model_checkpoint" => Ok(Self::ModelCheckpoint), | ||
| "llm_output" => Ok(Self::LlmOutput), | ||
| _ => Err(PayloadBoundError::InvalidPayloadDecision), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Positive byte and nesting-depth limits for one untrusted payload. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| pub struct PayloadBound { | ||
| max_bytes: usize, | ||
| max_depth: usize, | ||
| } | ||
|
|
||
| impl PayloadBound { | ||
| /// Construct a positive payload bound. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`PayloadBoundError::InvalidBound`] when either maximum is zero. | ||
| pub const fn new(max_bytes: usize, max_depth: usize) -> Result<Self, PayloadBoundError> { | ||
| if max_bytes == 0 || max_depth == 0 { | ||
| return Err(PayloadBoundError::InvalidBound); | ||
| } | ||
| Ok(Self { | ||
| max_bytes, | ||
| max_depth, | ||
| }) | ||
| } | ||
|
|
||
| /// Return the maximum accepted payload size in bytes. | ||
| #[must_use] | ||
| pub const fn max_bytes(self) -> usize { | ||
| self.max_bytes | ||
| } | ||
|
|
||
| /// Return the maximum accepted nesting depth. | ||
| #[must_use] | ||
| pub const fn max_depth(self) -> usize { | ||
| self.max_depth | ||
| } | ||
| } | ||
|
|
||
| /// Refuse an untrusted payload that lacks identity or provenance or exceeds | ||
| /// the configured size or depth bound. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns a missing-identity, missing-provenance, size, or depth error. | ||
| pub fn refuse_untrusted_payload( | ||
| kind: PayloadKind, | ||
| identity: Option<&str>, | ||
| provenance: Option<&str>, | ||
| byte_len: usize, | ||
| depth: usize, | ||
| bound: PayloadBound, | ||
| ) -> Result<(), PayloadBoundError> { | ||
| let _ = kind.wire_name(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: kind parameter is validated only cosmetically
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| match identity { | ||
| Some(value) if !value.is_empty() => {} | ||
| _ => return Err(PayloadBoundError::MissingIdentity), | ||
| } | ||
| match provenance { | ||
| Some(value) if !value.is_empty() => {} | ||
| _ => return Err(PayloadBoundError::MissingProvenance), | ||
| } | ||
| if byte_len > bound.max_bytes() { | ||
| return Err(PayloadBoundError::PayloadTooLarge); | ||
| } | ||
| if depth > bound.max_depth() { | ||
| return Err(PayloadBoundError::PayloadTooDeep); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Fraction of recovered accept/reject flags that match known truth. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`PayloadBoundError::InvalidPayloadDecision`] when either slice is | ||
| /// empty or the lengths differ. | ||
| pub fn identity_recovery_rate(truth: &[bool], decided: &[bool]) -> Result<f64, PayloadBoundError> { | ||
| if truth.is_empty() || truth.len() != decided.len() { | ||
| return Err(PayloadBoundError::InvalidPayloadDecision); | ||
| } | ||
| let mut matches = 0_u32; | ||
| for (truth_flag, decided_flag) in truth.iter().zip(decided) { | ||
| if truth_flag == decided_flag { | ||
| matches += 1; | ||
| } | ||
| } | ||
| Ok(f64::from(matches) / truth.len() as f64) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{PayloadBound, PayloadKind, identity_recovery_rate, refuse_untrusted_payload}; | ||
| use crate::PayloadBoundError; | ||
|
|
||
| #[test] | ||
| fn local_branches_cover_kinds_bounds_and_payloads() { | ||
| assert_eq!( | ||
| PayloadBound::new(0, 1), | ||
| Err(PayloadBoundError::InvalidBound) | ||
| ); | ||
| assert_eq!( | ||
| PayloadBound::new(1, 0), | ||
| Err(PayloadBoundError::InvalidBound) | ||
| ); | ||
| let bound = PayloadBound::new(8, 2).expect("bound"); | ||
| assert_eq!(bound.max_bytes(), 8); | ||
| assert_eq!(bound.max_depth(), 2); | ||
| assert_eq!( | ||
| refuse_untrusted_payload(PayloadKind::LlmOutput, None, Some("p"), 1, 1, bound), | ||
| Err(PayloadBoundError::MissingIdentity) | ||
| ); | ||
| assert_eq!( | ||
| refuse_untrusted_payload(PayloadKind::Document, Some(""), Some("p"), 1, 1, bound), | ||
| Err(PayloadBoundError::MissingIdentity) | ||
| ); | ||
| assert_eq!( | ||
| refuse_untrusted_payload(PayloadKind::SerializedRecord, Some("id"), None, 1, 1, bound), | ||
| Err(PayloadBoundError::MissingProvenance) | ||
| ); | ||
| assert_eq!( | ||
| refuse_untrusted_payload( | ||
| PayloadKind::ModelCheckpoint, | ||
| Some("id"), | ||
| Some(""), | ||
| 1, | ||
| 1, | ||
| bound | ||
| ), | ||
| Err(PayloadBoundError::MissingProvenance) | ||
| ); | ||
| assert_eq!( | ||
| refuse_untrusted_payload( | ||
| PayloadKind::ModelCheckpoint, | ||
| Some("id"), | ||
| Some("p"), | ||
| 9, | ||
| 1, | ||
| bound | ||
| ), | ||
| Err(PayloadBoundError::PayloadTooLarge) | ||
| ); | ||
| assert_eq!( | ||
| refuse_untrusted_payload(PayloadKind::Document, Some("id"), Some("p"), 1, 3, bound), | ||
| Err(PayloadBoundError::PayloadTooDeep) | ||
| ); | ||
| refuse_untrusted_payload(PayloadKind::Document, Some("id"), Some("p"), 8, 2, bound) | ||
| .expect("ok"); | ||
| for kind in [ | ||
| PayloadKind::Document, | ||
| PayloadKind::SerializedRecord, | ||
| PayloadKind::ModelCheckpoint, | ||
| PayloadKind::LlmOutput, | ||
| ] { | ||
| assert_eq!( | ||
| PayloadKind::from_wire_name(kind.wire_name()).expect("round-trip"), | ||
| kind | ||
| ); | ||
| } | ||
| assert_eq!( | ||
| PayloadKind::from_wire_name("trusted"), | ||
| Err(PayloadBoundError::InvalidPayloadDecision) | ||
| ); | ||
| let matched = identity_recovery_rate(&[true], &[true]).expect("rate"); | ||
| assert!((matched - 1.0).abs() < f64::EPSILON); | ||
| assert_eq!( | ||
| identity_recovery_rate(&[], &[]), | ||
| Err(PayloadBoundError::InvalidPayloadDecision) | ||
| ); | ||
| assert_eq!( | ||
| identity_recovery_rate(&[true], &[]), | ||
| Err(PayloadBoundError::InvalidPayloadDecision) | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| //! Fail-closed payload-bound errors. | ||
|
|
||
| use std::fmt; | ||
|
|
||
| /// A fail-closed payload-bound error. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum PayloadBoundError { | ||
| /// A maximum size or depth of zero was configured. | ||
| InvalidBound, | ||
| /// Identity was missing or empty. | ||
| MissingIdentity, | ||
| /// Provenance was missing or empty. | ||
| MissingProvenance, | ||
| /// The payload exceeded the configured byte bound. | ||
| PayloadTooLarge, | ||
| /// The payload exceeded the configured nesting-depth bound. | ||
| PayloadTooDeep, | ||
| /// A recovery slice was empty or length-mismatched. | ||
| InvalidPayloadDecision, | ||
| } | ||
|
|
||
| impl fmt::Display for PayloadBoundError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| let message = match self { | ||
| Self::InvalidBound => "payload bound must be positive", | ||
| Self::MissingIdentity => "untrusted payload is missing identity", | ||
| Self::MissingProvenance => "untrusted payload is missing provenance", | ||
| Self::PayloadTooLarge => "untrusted payload exceeds the byte bound", | ||
| Self::PayloadTooDeep => "untrusted payload exceeds the depth bound", | ||
| Self::InvalidPayloadDecision => "invalid payload-bound decision payload", | ||
| }; | ||
| formatter.write_str(message) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for PayloadBoundError {} | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::PayloadBoundError; | ||
|
|
||
| #[test] | ||
| fn error_messages_are_stable() { | ||
| for (error, message) in [ | ||
| ( | ||
| PayloadBoundError::InvalidBound, | ||
| "payload bound must be positive", | ||
| ), | ||
| ( | ||
| PayloadBoundError::MissingIdentity, | ||
| "untrusted payload is missing identity", | ||
| ), | ||
| ( | ||
| PayloadBoundError::MissingProvenance, | ||
| "untrusted payload is missing provenance", | ||
| ), | ||
| ( | ||
| PayloadBoundError::PayloadTooLarge, | ||
| "untrusted payload exceeds the byte bound", | ||
| ), | ||
| ( | ||
| PayloadBoundError::PayloadTooDeep, | ||
| "untrusted payload exceeds the depth bound", | ||
| ), | ||
| ( | ||
| PayloadBoundError::InvalidPayloadDecision, | ||
| "invalid payload-bound decision payload", | ||
| ), | ||
| ] { | ||
| assert_eq!(error.to_string(), message); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| #![forbid(unsafe_code)] | ||
| #![deny(missing_docs)] | ||
| #![allow(clippy::cast_precision_loss)] | ||
| //! Untrusted payloads fail closed without identity, provenance, size, and depth. | ||
| //! | ||
| //! Documents, serialized records, model checkpoints, and LLM outputs stay | ||
| //! untrusted until the owning boundary validates those four gates | ||
| //! (AGENTS.md; ADR 0008/0013). | ||
|
|
||
| mod bound; | ||
| mod error; | ||
|
|
||
| /// Positive byte and nesting-depth limits for one untrusted payload. | ||
| pub use bound::PayloadBound; | ||
| /// Closed vocabulary of untrusted inbound payload kinds. | ||
| pub use bound::PayloadKind; | ||
| /// Fraction of recovered accept/reject flags that match known truth. | ||
| pub use bound::identity_recovery_rate; | ||
| /// Refuse an untrusted payload that fails identity, provenance, size, or depth. | ||
| pub use bound::refuse_untrusted_payload; | ||
| /// Fail-closed payload-bound errors. | ||
| pub use error::PayloadBoundError; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| //! Integration contract for the `payload_bound` package identity. | ||
|
|
||
| #[test] | ||
| fn package_identity_is_stable() { | ||
| let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); | ||
| assert_eq!(observed, "payload_bound"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Duplicate retention changelog bullet
The new
payload_boundbullet at CHANGELOG.md:9 is accompanied by a second identicalpersistence_postgresmigration0007bullet at CHANGELOG.md:10, which already appears several times in the file. Likely a merge artifact, though it matches the file's existing duplication pattern.Was this helpful? React with 👍 or 👎 to provide feedback.