#![forbid(unsafe_code)]
mod redaction;
mod telemetry_payloads;
use std::{fmt, str::FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use thiserror::Error;
pub use redaction::{Redacted, redaction_marker, serialize_exposed};
pub use telemetry_payloads::{set_telemetry_payloads, telemetry_payloads};
const MAX_IDENTIFIER_LENGTH: usize = 253;
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum IdentifierError {
#[error("{kind} identifier must not be empty")]
Empty {
kind: &'static str,
},
#[error("{kind} identifier is {length} bytes; the maximum is {maximum}")]
TooLong {
kind: &'static str,
length: usize,
maximum: usize,
},
#[error(
"{kind} identifier must start with a lowercase ASCII letter or digit, found {character:?}"
)]
InvalidStart {
kind: &'static str,
character: char,
},
#[error(
"{kind} identifier must end with a lowercase ASCII letter or digit, found {character:?}"
)]
InvalidEnd {
kind: &'static str,
character: char,
},
#[error(
"{kind} identifier contains invalid character {character:?} at byte {index}; use lowercase ASCII letters, digits, '.', '-', or '_'"
)]
InvalidCharacter {
kind: &'static str,
index: usize,
character: char,
},
#[error("{kind} identifier contains adjacent separators at byte {index}")]
AdjacentSeparators {
kind: &'static str,
index: usize,
},
}
fn is_edge_character(character: char) -> bool {
character.is_ascii_lowercase() || character.is_ascii_digit()
}
fn is_separator(character: char) -> bool {
matches!(character, '.' | '-' | '_')
}
fn validate_identifier(value: &str, kind: &'static str) -> Result<(), IdentifierError> {
if value.is_empty() {
return Err(IdentifierError::Empty { kind });
}
if value.len() > MAX_IDENTIFIER_LENGTH {
return Err(IdentifierError::TooLong {
kind,
length: value.len(),
maximum: MAX_IDENTIFIER_LENGTH,
});
}
let mut characters = value.char_indices();
let (_, first) = characters.next().ok_or(IdentifierError::Empty { kind })?;
if !is_edge_character(first) {
return Err(IdentifierError::InvalidStart {
kind,
character: first,
});
}
let mut previous_was_separator = false;
for (index, character) in value.char_indices() {
if !is_edge_character(character) && !is_separator(character) {
return Err(IdentifierError::InvalidCharacter {
kind,
index,
character,
});
}
if is_separator(character) && previous_was_separator {
return Err(IdentifierError::AdjacentSeparators { kind, index });
}
previous_was_separator = is_separator(character);
}
let last = value
.chars()
.next_back()
.ok_or(IdentifierError::Empty { kind })?;
if !is_edge_character(last) {
return Err(IdentifierError::InvalidEnd {
kind,
character: last,
});
}
Ok(())
}
macro_rules! identifier {
($name:ident, $label:literal, $docs:literal) => {
#[doc = $docs]
#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl FromStr for $name {
type Err = IdentifierError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
validate_identifier(value, $label)?;
Ok(Self(value.to_owned()))
}
}
impl TryFrom<String> for $name {
type Error = IdentifierError;
fn try_from(value: String) -> Result<Self, Self::Error> {
validate_identifier(&value, $label)?;
Ok(Self(value))
}
}
impl TryFrom<&str> for $name {
type Error = IdentifierError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
impl From<$name> for String {
fn from(value: $name) -> Self {
value.0
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(D::Error::custom)
}
}
};
}
identifier!(AgentId, "agent", "A validated agent resource identifier.");
identifier!(
CapabilityId,
"capability",
"A validated capability resource identifier."
);
identifier!(
ProviderId,
"provider",
"A validated capability-provider identifier."
);
identifier!(TaskId, "task", "A validated task identifier.");
identifier!(
InvocationId,
"invocation",
"A validated capability invocation identifier."
);
identifier!(TraceId, "trace", "A validated end-to-end trace identifier.");
identifier!(
PrincipalId,
"principal",
"A validated authenticated principal identifier."
);
#[derive(Clone, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum Actor {
Human {
principal: PrincipalId,
},
Agent {
agent: AgentId,
},
Service {
principal: PrincipalId,
},
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "PascalCase")]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl fmt::Display for RiskLevel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{self:?}")
}
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "PascalCase")]
pub enum AgentStatus {
Disabled,
Pending,
Ready,
Error,
}
impl fmt::Display for AgentStatus {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{self:?}")
}
}
#[cfg(test)]
mod tests {
use super::{AgentId, IdentifierError, RiskLevel};
#[test]
fn accepts_portable_identifiers() {
for value in ["reviewer", "github.pull-request.read", "agent_2"] {
let parsed = value.parse::<AgentId>();
assert!(parsed.is_ok(), "{value} should be valid: {parsed:?}");
}
}
#[test]
fn rejects_invalid_identifiers_with_context() {
assert!(matches!(
"Reviewer".parse::<AgentId>(),
Err(IdentifierError::InvalidStart { .. })
));
assert!(matches!(
"github..read".parse::<AgentId>(),
Err(IdentifierError::AdjacentSeparators { .. })
));
assert!(matches!(
"reviewer/one".parse::<AgentId>(),
Err(IdentifierError::InvalidCharacter { index: 8, .. })
));
assert!(matches!(
"reviewer-".parse::<AgentId>(),
Err(IdentifierError::InvalidEnd { .. })
));
}
#[test]
fn deserialization_cannot_bypass_validation() {
let error = serde_json::from_str::<AgentId>(r#""not valid""#)
.expect_err("whitespace must be rejected");
assert!(error.to_string().contains("invalid character"));
}
#[test]
fn display_is_stable() {
assert_eq!(RiskLevel::High.to_string(), "High");
}
}