3da269c3b7
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
516 lines
16 KiB
Rust
516 lines
16 KiB
Rust
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,
|
|
Recurring,
|
|
Recovery,
|
|
Template,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum CommitmentState {
|
|
Draft,
|
|
Active,
|
|
Paused,
|
|
Completed,
|
|
Abandoned,
|
|
Violated,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RuntimeState {
|
|
Locked,
|
|
Planning,
|
|
Active,
|
|
Transition,
|
|
Review,
|
|
AdminOverride,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum EnforcementLevel {
|
|
Observe,
|
|
Warn,
|
|
Block,
|
|
Locked,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum EvidenceHealth {
|
|
Available,
|
|
Degraded(String),
|
|
Unavailable(String),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub struct AllowedContext {
|
|
pub window_classes: Vec<String>,
|
|
pub window_title_substrings: Vec<String>,
|
|
pub domains: Vec<String>,
|
|
pub repos: Vec<String>,
|
|
pub commands: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Commitment {
|
|
pub id: String,
|
|
pub created_at_unix_secs: u64,
|
|
pub source: CommitmentSource,
|
|
pub project_id: Option<String>,
|
|
pub template_id: Option<String>,
|
|
pub next_action: String,
|
|
pub success_condition: String,
|
|
pub timebox_secs: u64,
|
|
pub transition_policy_id: Option<String>,
|
|
pub state: CommitmentState,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct PolicySnapshot {
|
|
pub id: String,
|
|
pub commitment_id: String,
|
|
pub schema_version: u16,
|
|
pub created_at_unix_secs: u64,
|
|
pub runtime_state: RuntimeState,
|
|
pub enforcement_level: EnforcementLevel,
|
|
pub allowed_context: AllowedContext,
|
|
pub required_monitors: Vec<String>,
|
|
pub violation_actions: Vec<String>,
|
|
pub expires_at_unix_secs: Option<u64>,
|
|
pub generated_by_agent_version: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum CommitmentValidationError {
|
|
MissingId,
|
|
MissingNextAction,
|
|
MissingSuccessCondition,
|
|
MissingTimebox,
|
|
}
|
|
|
|
impl fmt::Display for CommitmentValidationError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
CommitmentValidationError::MissingId => write!(f, "commitment id is required"),
|
|
CommitmentValidationError::MissingNextAction => write!(f, "next action is required"),
|
|
CommitmentValidationError::MissingSuccessCondition => {
|
|
write!(f, "success condition is required")
|
|
}
|
|
CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error 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 std::error::Error for PolicySnapshotValidationError {}
|
|
|
|
impl Commitment {
|
|
pub fn new_manual(
|
|
next_action: impl Into<String>,
|
|
success_condition: impl Into<String>,
|
|
timebox: Duration,
|
|
) -> Result<Self, CommitmentValidationError> {
|
|
let next_action = next_action.into().trim().to_string();
|
|
let success_condition = success_condition.into().trim().to_string();
|
|
|
|
if timebox.as_secs() == 0 {
|
|
return Err(CommitmentValidationError::MissingTimebox);
|
|
}
|
|
|
|
let created_at_unix_secs = unix_secs_now();
|
|
let commitment = Self {
|
|
id: unique_id("commitment"),
|
|
created_at_unix_secs,
|
|
source: CommitmentSource::Manual,
|
|
project_id: None,
|
|
template_id: None,
|
|
next_action,
|
|
success_condition,
|
|
timebox_secs: timebox.as_secs(),
|
|
transition_policy_id: None,
|
|
state: CommitmentState::Draft,
|
|
};
|
|
commitment.validate()?;
|
|
Ok(commitment)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), CommitmentValidationError> {
|
|
if self.id.trim().is_empty() {
|
|
return Err(CommitmentValidationError::MissingId);
|
|
}
|
|
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(())
|
|
}
|
|
}
|
|
|
|
impl PolicySnapshot {
|
|
pub fn for_runtime(
|
|
commitment_id: String,
|
|
runtime_state: RuntimeState,
|
|
enforcement_level: EnforcementLevel,
|
|
allowed_context: AllowedContext,
|
|
) -> Self {
|
|
Self::try_for_runtime(
|
|
commitment_id,
|
|
runtime_state,
|
|
enforcement_level,
|
|
allowed_context,
|
|
)
|
|
.expect("runtime policy snapshot requires a non-empty commitment id")
|
|
}
|
|
|
|
pub fn try_for_runtime(
|
|
commitment_id: String,
|
|
runtime_state: RuntimeState,
|
|
enforcement_level: EnforcementLevel,
|
|
allowed_context: AllowedContext,
|
|
) -> Result<Self, PolicySnapshotValidationError> {
|
|
let commitment_id = commitment_id.trim().to_string();
|
|
if commitment_id.is_empty() {
|
|
return Err(PolicySnapshotValidationError::MissingCommitmentId);
|
|
}
|
|
|
|
let created_at_unix_secs = unix_secs_now();
|
|
let policy = Self {
|
|
id: unique_id("policy"),
|
|
commitment_id,
|
|
schema_version: POLICY_SCHEMA_VERSION,
|
|
created_at_unix_secs,
|
|
runtime_state,
|
|
enforcement_level,
|
|
allowed_context,
|
|
required_monitors: Vec::new(),
|
|
violation_actions: Vec::new(),
|
|
expires_at_unix_secs: None,
|
|
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
};
|
|
policy.validate()?;
|
|
Ok(policy)
|
|
}
|
|
|
|
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 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
fn unique_id(prefix: &str) -> String {
|
|
format!("{prefix}-{}", Uuid::now_v7())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn commitment_requires_concrete_action_and_success_condition() {
|
|
let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500))
|
|
.expect_err("empty next action must be rejected");
|
|
assert_eq!(err, CommitmentValidationError::MissingNextAction);
|
|
|
|
let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500))
|
|
.expect_err("empty success condition must be rejected");
|
|
assert_eq!(err, CommitmentValidationError::MissingSuccessCondition);
|
|
}
|
|
|
|
#[test]
|
|
fn commitment_requires_nonzero_timebox() {
|
|
let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO)
|
|
.expect_err("zero timebox must be rejected");
|
|
assert_eq!(err, CommitmentValidationError::MissingTimebox);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_snapshot_carries_runtime_contract() {
|
|
let commitment = Commitment::new_manual(
|
|
"Implement domain types",
|
|
"domain tests pass",
|
|
Duration::from_secs(1500),
|
|
)
|
|
.unwrap();
|
|
let policy = PolicySnapshot::for_runtime(
|
|
commitment.id.clone(),
|
|
RuntimeState::Active,
|
|
EnforcementLevel::Warn,
|
|
AllowedContext::default(),
|
|
);
|
|
|
|
assert_eq!(policy.commitment_id, commitment.id);
|
|
assert_eq!(policy.runtime_state, RuntimeState::Active);
|
|
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.id = " ".to_string();
|
|
assert_eq!(
|
|
commitment.validate(),
|
|
Err(CommitmentValidationError::MissingId)
|
|
);
|
|
|
|
commitment.id = "commitment-1".to_string();
|
|
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": "",
|
|
"created_at_unix_secs": 1,
|
|
"source": "manual",
|
|
"project_id": null,
|
|
"template_id": null,
|
|
"next_action": "Refactor state",
|
|
"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::MissingId)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validation_errors_implement_std_error() {
|
|
fn assert_std_error<E: std::error::Error>() {}
|
|
|
|
assert_std_error::<CommitmentValidationError>();
|
|
assert_std_error::<PolicySnapshotValidationError>();
|
|
}
|
|
|
|
#[test]
|
|
fn policy_snapshot_try_for_runtime_rejects_empty_commitment_id() {
|
|
let err = PolicySnapshot::try_for_runtime(
|
|
" ".to_string(),
|
|
RuntimeState::Active,
|
|
EnforcementLevel::Warn,
|
|
AllowedContext::default(),
|
|
)
|
|
.expect_err("empty commitment id must be rejected");
|
|
|
|
assert_eq!(err, PolicySnapshotValidationError::MissingCommitmentId);
|
|
}
|
|
|
|
#[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)
|
|
);
|
|
}
|
|
}
|