diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index e9aa926..cebd594 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -737,6 +737,27 @@ async fn command( .into_response(); } }; + let player_id = match load_campaign(&runtime.store) { + Ok(campaign) => campaign.player_actor_id, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorBody { + error: error.to_string(), + }), + ) + .into_response(); + } + }; + if !player_http_command_allowed(&command, &player_id) { + return ( + StatusCode::FORBIDDEN, + Json(ErrorBody { + error: "command is not admitted through the player HTTP boundary".into(), + }), + ) + .into_response(); + } if let Err(error) = process_due_ticks( &state, &runtime, @@ -1011,6 +1032,24 @@ async fn command( } } +fn player_http_command_allowed(command: &WorldCommand, player_actor_id: &str) -> bool { + match command { + WorldCommand::Speak { actor_id, .. } => actor_id == player_actor_id, + WorldCommand::Assess { + intent, proposal, .. + } => intent.actor_id == player_actor_id && proposal.is_none(), + WorldCommand::Attempt { .. } | WorldCommand::Wait { .. } => true, + WorldCommand::CreateCampaign { .. } + | WorldCommand::AdvanceStrategicTick { .. } + | WorldCommand::ExpandRegion { .. } + | WorldCommand::MaterializeGestaltMember { .. } + | WorldCommand::DematerializeGestaltMember { .. } + | WorldCommand::ReconcileGestaltPresence { .. } + | WorldCommand::ResolveReactionWave { .. } + | WorldCommand::BeginNpcAction { .. } => false, + } +} + async fn scheduler_loop(state: AppState) { let mut pulse = tokio::time::interval(std::time::Duration::from_secs(300)); pulse.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -1603,4 +1642,49 @@ mod tests { ); assert!(!state.auth.lock().await.state.session_campaign_ids["left"].contains(&right.id)); } + + #[test] + fn player_http_boundary_cannot_invoke_internal_world_commands_or_npcs() { + assert!(player_http_command_allowed( + &WorldCommand::Wait { + expected_revision: 4, + minutes: 10, + }, + "player", + )); + assert!(player_http_command_allowed( + &WorldCommand::Speak { + expected_revision: 4, + actor_id: "player".into(), + text: "Hello.".into(), + intended_effect: None, + }, + "player", + )); + assert!(!player_http_command_allowed( + &WorldCommand::Speak { + expected_revision: 4, + actor_id: "npc".into(), + text: "I have been puppeted.".into(), + intended_effect: None, + }, + "player", + )); + assert!(!player_http_command_allowed( + &WorldCommand::AdvanceStrategicTick { + expected_revision: 4, + source: ghostlight_dungeon::domain::TickSource::Scheduler, + plan: None, + }, + "player", + )); + assert!(!player_http_command_allowed( + &WorldCommand::ReconcileGestaltPresence { + expected_revision: 4, + reason: "browser says so".into(), + plan: Default::default(), + }, + "player", + )); + } } diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 7bfff48..94c6d9b 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -36,6 +36,13 @@ the campaign revision and world time untouched. Background inference checks live-turn pressure before launch and again before commit; return catch-up uses the same command path with player-turn priority. +The browser command boundary is deliberately smaller than `WorldCommand`. +Authenticated HTTP admits only player-owned Speak, unfilled Assess, Attempt, +and Wait requests. Actor identity must match the campaign player. Compiler +approval has its own route. Strategic ticks, region commits, gestalt presence, +reaction waves, NPC initiative, and campaign creation are internal mailbox +commands and cannot be invoked through `/api/command`. + ## Control Flow 1. Rehydrate from `state/map.yaml`, `notes/fresh-workspace-handoff.md`, this diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9f84a83..9e18b04 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -74,3 +74,4 @@ {"note": "Disposable HTTP acceptance proved unauthenticated 401, compiler-only surface before approval, explicit campaign admission with three evidence receipts, campaign reload after daemon restart, authenticated session survival after restart, and 401 on consumed-invite reuse. Auth CultCache stores token/session hashes rather than plaintext.", "status": "ok", "ts": "2026-08-16T18:17:14+00:00", "type": "verification"} {"ts": "2026-08-16T21:10:00+00:00", "type": "verification", "status": "ok", "branch": "codex/ghostlight-dungeon-mvp", "note": "Registry HTTP acceptance at commit 381cbf2: two single-use invite sessions compiled distinct live DeepSeek/VoidBot-grounded campaigns with different IDs, surfaces, evidence receipts, and per-campaign .cc stores; cross-session selection returned 403. Fork, reset, 1,056,768-byte CultCache export, session/campaign survival across daemon restart, rejected-command receipt without revision advance, committed Wait narration, and zero settled live-turn pressure all passed. Disposable witness root: F:\\GameCult\\GhostlightDungeon\\smoke\\registry-381cbf2."} {"ts":"2026-08-16T20:04:54+00:00","type":"implementation","status":"ok","branch":"codex/ghostlight-dungeon-mvp","note":"Away-time agency now uses a revision-bound deepseek-v4-flash StrategicTickPlan proposal. WorldKernel validation rejects player puppeting, invented institutions/gestalts/actors/locations, duplicate actions, movement without a direct route or beyond tick duration, empty summaries, and unbounded channels or population pressures before mutation. The 30-test Rust suite proves a rejected player move leaves revision/location unchanged and a valid remote actor move commits one event."} +{"ts":"2026-08-16T20:12:33+00:00","type":"implementation","status":"ok","branch":"codex/ghostlight-dungeon-mvp","note":"The player HTTP mouth now admits only player-owned Speak, unfilled Assess, Attempt, and Wait commands. It rejects NPC impersonation and every internal WorldCommand family, including strategic ticks, region expansion, gestalt presence, reaction waves, NPC initiative, and campaign creation, before catch-up or kernel dispatch. Focused HTTP-boundary tests and the complete Rust suite pass."}