diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index b8c44d3..af272d7 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -118,6 +118,7 @@ fn execute( proposal, } => { require_revision(&campaign, expected_revision)?; + validate_intent_text(&intent)?; let assessment = match proposal { Some(assessment) => { if assessment.campaign_id != campaign.id @@ -215,6 +216,10 @@ fn execute( intended_effect, } => { require_revision(&campaign, expected_revision)?; + validate_bounded_text("speech", &text, 4_000)?; + if let Some(effect) = &intended_effect { + validate_bounded_text("intended effect", effect, 1_000)?; + } if !campaign.actors.contains_key(&actor_id) { return Err(KernelError::Invalid("unknown actor".into())); } @@ -248,6 +253,11 @@ fn execute( minutes, } => { require_revision(&campaign, expected_revision)?; + if minutes == 0 || minutes > 1_440 { + return Err(KernelError::Invalid( + "wait duration must be between 1 and 1440 minutes".into(), + )); + } campaign.world_time += Duration::minutes(i64::from(minutes)); campaign.last_player_activity = Utc::now(); campaign.away_ticks_processed = 0; @@ -866,6 +876,25 @@ fn execute( } } +fn validate_intent_text(intent: &ActionIntent) -> Result<(), KernelError> { + validate_bounded_text("action description", &intent.description, 4_000)?; + validate_bounded_text("intended effect", &intent.intended_effect, 1_000) +} + +fn validate_bounded_text(label: &str, value: &str, max_chars: usize) -> Result<(), KernelError> { + if value.trim().is_empty() + || value.chars().count() > max_chars + || value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(KernelError::Invalid(format!( + "{label} must contain 1 to {max_chars} readable characters" + ))); + } + Ok(()) +} + fn apply_individuation( campaign: &mut Campaign, individuation: &GestaltIndividuation, @@ -3707,6 +3736,55 @@ mod tests { assert_eq!(stored.world_time, seed.world_time + Duration::minutes(30)); } + #[tokio::test] + async fn invalid_wait_and_oversized_speech_cannot_mutate_campaign() { + let dir = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap(); + let kernel = WorldKernel::start(store.clone()); + let seed = campaign(); + kernel + .command(WorldCommand::CreateCampaign { + campaign: seed.clone(), + evidence_receipts: vec![], + model_stage_receipts: vec![], + }) + .await + .unwrap(); + let baseline = store + .load::("campaign.v1", &seed.id.to_string()) + .unwrap() + .unwrap() + .1; + + assert!( + kernel + .command(WorldCommand::Wait { + expected_revision: 0, + minutes: 1_441, + }) + .await + .is_err() + ); + assert!( + kernel + .command(WorldCommand::Speak { + expected_revision: 0, + actor_id: "player".into(), + text: "x".repeat(4_001), + intended_effect: None, + }) + .await + .is_err() + ); + let stored = store + .load::("campaign.v1", &seed.id.to_string()) + .unwrap() + .unwrap() + .1; + assert_eq!(stored, baseline); + assert!(store.keys("world_commit_receipt.v1").unwrap().is_empty()); + } + #[tokio::test] async fn npc_speech_does_not_impersonate_player_activity() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 882fcbf..4276af2 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -916,6 +916,9 @@ async fn command( ) .into_response(); } + if let Err(error) = validate_player_http_command(&command) { + return (StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorBody { error })).into_response(); + } if let Err(error) = process_due_ticks( &state, &runtime, @@ -1442,6 +1445,47 @@ fn player_http_command_allowed(command: &WorldCommand, player_actor_id: &str) -> } } +fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> { + let bounded = |label: &str, value: &str, max: usize| { + if value.trim().is_empty() || value.chars().count() > max { + Err(format!("{label} must contain 1 to {max} characters")) + } else { + Ok(()) + } + }; + match command { + WorldCommand::Speak { + text, + intended_effect, + .. + } => { + bounded("speech", text, 4_000)?; + if intended_effect.is_some() { + return Err( + "speech and uncertain intended effects use separate player commands".into(), + ); + } + Ok(()) + } + WorldCommand::Assess { intent, .. } => { + bounded("action description", &intent.description, 4_000)?; + bounded("intended effect", &intent.intended_effect, 1_000) + } + WorldCommand::Attempt { assessment_digest } => { + bounded("assessment digest", assessment_digest, 160) + } + WorldCommand::Wait { minutes, .. } if *minutes == 0 || *minutes > 1_440 => { + Err("wait duration must be between 1 and 1440 minutes".into()) + } + WorldCommand::SetResolutionBudget { + active_cell_budget, .. + } if !(1..=32).contains(active_cell_budget) => { + Err("active Persona-cell budget must be between 1 and 32".into()) + } + _ => Ok(()), + } +} + fn reaction_stimulus(command: &WorldCommand) -> Option { match command { WorldCommand::Speak { actor_id, text, .. } => { @@ -2474,6 +2518,53 @@ mod tests { )); } + #[test] + fn player_http_command_admission_bounds_cost_and_fictional_time() { + assert!( + validate_player_http_command(&WorldCommand::Wait { + expected_revision: 0, + minutes: 0, + }) + .is_err() + ); + assert!( + validate_player_http_command(&WorldCommand::Wait { + expected_revision: 0, + minutes: 1_441, + }) + .is_err() + ); + assert!( + validate_player_http_command(&WorldCommand::SetResolutionBudget { + expected_revision: 0, + expected_resolution_epoch: 0, + active_cell_budget: 33, + }) + .is_err() + ); + assert!( + validate_player_http_command(&WorldCommand::Speak { + expected_revision: 0, + actor_id: "player".into(), + text: "Hello".into(), + intended_effect: Some("obey me".into()), + }) + .is_err() + ); + assert!( + validate_player_http_command(&WorldCommand::Assess { + expected_revision: 0, + intent: ActionIntent { + actor_id: "player".into(), + description: "x".repeat(4_001), + intended_effect: "cross the room".into(), + }, + proposal: None, + }) + .is_err() + ); + } + #[test] fn operator_http_boundary_is_loopback_only() { assert!(operator_peer_allowed("127.0.0.1:8831".parse().unwrap()));