Restrict the player command boundary

This commit is contained in:
Meta Vens 2026-08-16 22:12:46 +02:00
commit 3d64aa973e
3 changed files with 92 additions and 0 deletions

View file

@ -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",
));
}
}

View file

@ -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

View file

@ -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."}