Harden commitment domain contracts
This commit is contained in:
+250
-11
@@ -1,10 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const POLICY_SCHEMA_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommitmentSource {
|
||||
Manual,
|
||||
Planner,
|
||||
@@ -14,6 +16,7 @@ pub enum CommitmentSource {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommitmentState {
|
||||
Draft,
|
||||
Active,
|
||||
@@ -24,6 +27,7 @@ pub enum CommitmentState {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeState {
|
||||
Locked,
|
||||
Planning,
|
||||
@@ -34,6 +38,7 @@ pub enum RuntimeState {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EnforcementLevel {
|
||||
Observe,
|
||||
Warn,
|
||||
@@ -42,6 +47,7 @@ pub enum EnforcementLevel {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvidenceHealth {
|
||||
Available,
|
||||
Degraded(String),
|
||||
@@ -105,6 +111,31 @@ impl fmt::Display for CommitmentValidationError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PolicySnapshotValidationError {
|
||||
WrongSchemaVersion,
|
||||
MissingId,
|
||||
MissingCommitmentId,
|
||||
MissingGeneratedByAgentVersion,
|
||||
}
|
||||
|
||||
impl fmt::Display for PolicySnapshotValidationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PolicySnapshotValidationError::WrongSchemaVersion => {
|
||||
write!(f, "policy schema version is unsupported")
|
||||
}
|
||||
PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"),
|
||||
PolicySnapshotValidationError::MissingCommitmentId => {
|
||||
write!(f, "commitment id is required")
|
||||
}
|
||||
PolicySnapshotValidationError::MissingGeneratedByAgentVersion => {
|
||||
write!(f, "generated agent version is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Commitment {
|
||||
pub fn new_manual(
|
||||
next_action: impl Into<String>,
|
||||
@@ -114,19 +145,13 @@ impl Commitment {
|
||||
let next_action = next_action.into().trim().to_string();
|
||||
let success_condition = success_condition.into().trim().to_string();
|
||||
|
||||
if next_action.is_empty() {
|
||||
return Err(CommitmentValidationError::MissingNextAction);
|
||||
}
|
||||
if success_condition.is_empty() {
|
||||
return Err(CommitmentValidationError::MissingSuccessCondition);
|
||||
}
|
||||
if timebox.is_zero() {
|
||||
if timebox.as_secs() == 0 {
|
||||
return Err(CommitmentValidationError::MissingTimebox);
|
||||
}
|
||||
|
||||
let created_at_unix_secs = unix_secs_now();
|
||||
Ok(Self {
|
||||
id: format!("commitment-{created_at_unix_secs}"),
|
||||
let commitment = Self {
|
||||
id: unique_id("commitment"),
|
||||
created_at_unix_secs,
|
||||
source: CommitmentSource::Manual,
|
||||
project_id: None,
|
||||
@@ -136,7 +161,22 @@ impl Commitment {
|
||||
timebox_secs: timebox.as_secs(),
|
||||
transition_policy_id: None,
|
||||
state: CommitmentState::Draft,
|
||||
})
|
||||
};
|
||||
commitment.validate()?;
|
||||
Ok(commitment)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), CommitmentValidationError> {
|
||||
if self.next_action.trim().is_empty() {
|
||||
return Err(CommitmentValidationError::MissingNextAction);
|
||||
}
|
||||
if self.success_condition.trim().is_empty() {
|
||||
return Err(CommitmentValidationError::MissingSuccessCondition);
|
||||
}
|
||||
if self.timebox_secs == 0 {
|
||||
return Err(CommitmentValidationError::MissingTimebox);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +189,7 @@ impl PolicySnapshot {
|
||||
) -> Self {
|
||||
let created_at_unix_secs = unix_secs_now();
|
||||
Self {
|
||||
id: format!("policy-{created_at_unix_secs}"),
|
||||
id: unique_id("policy"),
|
||||
commitment_id,
|
||||
schema_version: POLICY_SCHEMA_VERSION,
|
||||
created_at_unix_secs,
|
||||
@@ -162,6 +202,22 @@ impl PolicySnapshot {
|
||||
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> {
|
||||
if self.schema_version != POLICY_SCHEMA_VERSION {
|
||||
return Err(PolicySnapshotValidationError::WrongSchemaVersion);
|
||||
}
|
||||
if self.id.trim().is_empty() {
|
||||
return Err(PolicySnapshotValidationError::MissingId);
|
||||
}
|
||||
if self.commitment_id.trim().is_empty() {
|
||||
return Err(PolicySnapshotValidationError::MissingCommitmentId);
|
||||
}
|
||||
if self.generated_by_agent_version.trim().is_empty() {
|
||||
return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unix_secs_now() -> u64 {
|
||||
@@ -171,6 +227,10 @@ pub fn unix_secs_now() -> u64 {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn unique_id(prefix: &str) -> String {
|
||||
format!("{prefix}-{}", Uuid::now_v7())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -214,4 +274,183 @@ mod tests {
|
||||
assert_eq!(policy.enforcement_level, EnforcementLevel::Warn);
|
||||
assert_eq!(policy.schema_version, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commitment_ids_are_unique_for_rapid_consecutive_construction() {
|
||||
let first = Commitment::new_manual(
|
||||
"Implement first action",
|
||||
"first tests pass",
|
||||
Duration::from_secs(1500),
|
||||
)
|
||||
.unwrap();
|
||||
let second = Commitment::new_manual(
|
||||
"Implement second action",
|
||||
"second tests pass",
|
||||
Duration::from_secs(1500),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(first.id, second.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() {
|
||||
let first = PolicySnapshot::for_runtime(
|
||||
"commitment-1".to_string(),
|
||||
RuntimeState::Active,
|
||||
EnforcementLevel::Warn,
|
||||
AllowedContext::default(),
|
||||
);
|
||||
let second = PolicySnapshot::for_runtime(
|
||||
"commitment-1".to_string(),
|
||||
RuntimeState::Active,
|
||||
EnforcementLevel::Warn,
|
||||
AllowedContext::default(),
|
||||
);
|
||||
|
||||
assert_ne!(first.id, second.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commitment_serializes_enum_contract_as_snake_case() {
|
||||
let commitment = Commitment::new_manual(
|
||||
"Implement domain contracts",
|
||||
"domain tests pass",
|
||||
Duration::from_secs(1500),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_string(&commitment).unwrap();
|
||||
|
||||
assert!(json.contains(r#""source":"manual""#));
|
||||
assert!(json.contains(r#""state":"draft""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_snapshot_serializes_enum_contract_as_snake_case() {
|
||||
let policy = PolicySnapshot::for_runtime(
|
||||
"commitment-1".to_string(),
|
||||
RuntimeState::Active,
|
||||
EnforcementLevel::Warn,
|
||||
AllowedContext::default(),
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&policy).unwrap();
|
||||
|
||||
assert!(json.contains(r#""runtime_state":"active""#));
|
||||
assert!(json.contains(r#""enforcement_level":"warn""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commitment_validate_rejects_invalid_public_field_values() {
|
||||
let mut commitment =
|
||||
Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500))
|
||||
.unwrap();
|
||||
commitment.next_action = " ".to_string();
|
||||
assert_eq!(
|
||||
commitment.validate(),
|
||||
Err(CommitmentValidationError::MissingNextAction)
|
||||
);
|
||||
|
||||
commitment.next_action = "Refactor state".to_string();
|
||||
commitment.success_condition = " ".to_string();
|
||||
assert_eq!(
|
||||
commitment.validate(),
|
||||
Err(CommitmentValidationError::MissingSuccessCondition)
|
||||
);
|
||||
|
||||
commitment.success_condition = "tests pass".to_string();
|
||||
commitment.timebox_secs = 0;
|
||||
assert_eq!(
|
||||
commitment.validate(),
|
||||
Err(CommitmentValidationError::MissingTimebox)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commitment_validate_rejects_invalid_deserialized_values() {
|
||||
let json = r#"{
|
||||
"id": "commitment-1",
|
||||
"created_at_unix_secs": 1,
|
||||
"source": "manual",
|
||||
"project_id": null,
|
||||
"template_id": null,
|
||||
"next_action": "",
|
||||
"success_condition": "tests pass",
|
||||
"timebox_secs": 1500,
|
||||
"transition_policy_id": null,
|
||||
"state": "draft"
|
||||
}"#;
|
||||
let commitment: Commitment = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
commitment.validate(),
|
||||
Err(CommitmentValidationError::MissingNextAction)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_snapshot_validate_rejects_invalid_public_field_values() {
|
||||
let mut policy = PolicySnapshot::for_runtime(
|
||||
"commitment-1".to_string(),
|
||||
RuntimeState::Active,
|
||||
EnforcementLevel::Warn,
|
||||
AllowedContext::default(),
|
||||
);
|
||||
policy.schema_version = POLICY_SCHEMA_VERSION + 1;
|
||||
assert_eq!(
|
||||
policy.validate(),
|
||||
Err(PolicySnapshotValidationError::WrongSchemaVersion)
|
||||
);
|
||||
|
||||
policy.schema_version = POLICY_SCHEMA_VERSION;
|
||||
policy.id = " ".to_string();
|
||||
assert_eq!(
|
||||
policy.validate(),
|
||||
Err(PolicySnapshotValidationError::MissingId)
|
||||
);
|
||||
|
||||
policy.id = "policy-1".to_string();
|
||||
policy.commitment_id = " ".to_string();
|
||||
assert_eq!(
|
||||
policy.validate(),
|
||||
Err(PolicySnapshotValidationError::MissingCommitmentId)
|
||||
);
|
||||
|
||||
policy.commitment_id = "commitment-1".to_string();
|
||||
policy.generated_by_agent_version = " ".to_string();
|
||||
assert_eq!(
|
||||
policy.validate(),
|
||||
Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_snapshot_validate_rejects_invalid_deserialized_values() {
|
||||
let json = r#"{
|
||||
"id": "",
|
||||
"commitment_id": "commitment-1",
|
||||
"schema_version": 1,
|
||||
"created_at_unix_secs": 1,
|
||||
"runtime_state": "active",
|
||||
"enforcement_level": "warn",
|
||||
"allowed_context": {
|
||||
"window_classes": [],
|
||||
"window_title_substrings": [],
|
||||
"domains": [],
|
||||
"repos": [],
|
||||
"commands": []
|
||||
},
|
||||
"required_monitors": [],
|
||||
"violation_actions": [],
|
||||
"expires_at_unix_secs": null,
|
||||
"generated_by_agent_version": "0.1.0"
|
||||
}"#;
|
||||
let policy: PolicySnapshot = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
policy.validate(),
|
||||
Err(PolicySnapshotValidationError::MissingId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user