Restrict the player command boundary
This commit is contained in:
parent
18ce95773e
commit
3d64aa973e
3 changed files with 92 additions and 0 deletions
|
|
@ -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",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue