diff --git a/docs/bvm-reference.md b/docs/bvm-reference.md index 8592c94e..5b0dd0e2 100644 --- a/docs/bvm-reference.md +++ b/docs/bvm-reference.md @@ -268,9 +268,9 @@ none = void/Bool. Parameter sigils inside the argument list use the same. | `CREATEEMITTER` | `CREATEEMITTER(PARAM1%, PARAM2$, PARAM3%, PARAM4%, PARAM5#=0, PARAM6#=0, PARAM7#=0, PARAM8%=0)` | None | | `DAY` | `DAY()` : Int | None | | `DEFAULTFACTIONRATING` | `DEFAULTFACTIONRATING(PARAM1$, PARAM2$)` : Int | None | -| `DELETEABILITY` | `DELETEABILITY(PARAM1%, PARAM2$)` | None | +| `DELETEABILITY` | `DELETEABILITY(PARAM1%, PARAM2$)` | Privileged | | `DELETEACTOREFFECT` | `DELETEACTOREFFECT(PARAM1%, PARAM2$)` | None | -| `DELETEQUEST` | `DELETEQUEST(PARAM1%, PARAM2$)` | None | +| `DELETEQUEST` | `DELETEQUEST(PARAM1%, PARAM2$)` | Privileged | | `DEQUOTE` | `DEQUOTE(PARAM1$)` : String | None | | `DOTTEDIP` | `DOTTEDIP(Param1%)` : String | Privileged | | `FINDACTOR` | `FINDACTOR(PARAM1$, ACTORTYPE% = 3)` : Int | None | diff --git a/src/Modules/ScriptingCommands.bb b/src/Modules/ScriptingCommands.bb index 485a69fc..8801185c 100644 --- a/src/Modules/ScriptingCommands.bb +++ b/src/Modules/ScriptingCommands.bb @@ -1564,6 +1564,16 @@ Function BVM_ADDABILITY(Param1%, Param2$, Param3%=1) End Function Function BVM_DELETEABILITY(Param1%, Param2$) + ; Full-priv gate. Strips an ability/spell from any actor handle -- + ; equivalent-effect bypass of the already-gated BVM_SETABILITYLEVEL + ; (which gates because zeroing ability levels bricks a player's + ; combat toolkit; deleting the ability outright is strictly worse). + ; For Examine/Trade/RightClick/ItemScript spawns SI\AI = Handle(clicker), + ; so a self-or-priv gate would let DeleteAbility(clicker, "") + ; through -- RequirePrivileged refuses regardless of the target. + ; No shipped content script calls DeleteAbility (grep of data/ found + ; zero callers), so the gate breaks nothing. + If Not BVM_RequirePrivileged() Then Return Actor.ActorInstance = Object.ActorInstance(Param1%) If Actor <> Null SpellName$ = Upper$(Param2$) @@ -2222,6 +2232,15 @@ Function BVM_COMPLETEQUEST(Param1%, Param2$) End Function Function BVM_DELETEQUEST(Param1%, Param2$) + ; Full-priv gate. Wipes a target's quest-log entry -- equivalent-effect + ; bypass of the gated quest/progression mutators: a non-priv clicker + ; script could erase a player's quest progress. For Examine/Trade/ + ; RightClick/ItemScript spawns SI\AI = Handle(clicker), so a self-or-priv + ; gate would let DeleteQuest(clicker, "") through -- + ; RequirePrivileged refuses regardless of the target. No shipped content + ; script calls DeleteQuest (grep of data/ found zero callers), so the + ; gate breaks nothing. + If Not BVM_RequirePrivileged() Then Return Actor.ActorInstance = Object.ActorInstance(Param1%) If Actor <> Null If Actor\RNID > 0 diff --git a/src/Tests/Modules/BVMPrivilegeGateTest.bb b/src/Tests/Modules/BVMPrivilegeGateTest.bb index c33bd8a4..7a5ab193 100644 --- a/src/Tests/Modules/BVMPrivilegeGateTest.bb +++ b/src/Tests/Modules/BVMPrivilegeGateTest.bb @@ -30,6 +30,8 @@ EnableGC ; BVM_REMOVEZONEINSTANCE (admin-only) RequirePrivileged ; BVM_SETACTORGLOBAL (per-actor state) RequireSelfOrPrivileged ; BVM_SETSUPERGLOBAL (server-wide state) RequirePrivileged +; BVM_DELETEABILITY BVM_SETABILITYLEVEL RequirePrivileged +; BVM_DELETEQUEST (quest-log wipe) RequirePrivileged ; ; Four sibling functions (SETACTORAISTATE / SETACTORTARGET / SETNAME / ; SETTAG) intentionally stay UNGATED -- shipped content scripts in @@ -111,6 +113,8 @@ Global MutationSetActorGroup = 0 Global MutationRemoveZone = 0 Global MutationSetActorGlobal = 0 ; self-or-priv gated Global MutationSetSuperGlobal = 0 ; full-priv gated +Global MutationDeleteAbility = 0 ; full-priv gated +Global MutationDeleteQuest = 0 ; full-priv gated Function MockBVM_CHANGEGOLD(Param1%, Param2%) If Not BVM_RequirePrivileged() Then Return @@ -225,6 +229,24 @@ Function MockBVM_SETSUPERGLOBAL(Param1%, Param2$) MutationSetSuperGlobal = MutationSetSuperGlobal + 1 End Function +; DeleteAbility gate. Strips an ability/spell from any actor handle -- +; equivalent-effect bypass of the gated SETABILITYLEVEL (deleting is +; strictly worse than zeroing the level). Param1 is an actor handle and +; the clicker-spawn shape makes SI\AI = Handle(clicker), so full-priv +; (not self-or-priv). Zero shipped content-script callers. +Function MockBVM_DELETEABILITY(Param1%, Param2$) + If Not BVM_RequirePrivileged() Then Return + MutationDeleteAbility = MutationDeleteAbility + 1 +End Function + +; DeleteQuest gate. Wipes a target's quest-log entry -- a non-priv +; clicker script could erase a player's quest progress. Same clicker +; SI\AI = Handle(clicker) shape, so full-priv. Zero shipped callers. +Function MockBVM_DELETEQUEST(Param1%, Param2$) + If Not BVM_RequirePrivileged() Then Return + MutationDeleteQuest = MutationDeleteQuest + 1 +End Function + ; --- Test fixture helpers ---------------------------------------------- Function ResetMutationCounters() MutationGold = 0 @@ -245,6 +267,8 @@ Function ResetMutationCounters() MutationRemoveZone = 0 MutationSetActorGlobal = 0 MutationSetSuperGlobal = 0 + MutationDeleteAbility = 0 + MutationDeleteQuest = 0 LastScriptLog$ = "" End Function @@ -807,3 +831,66 @@ Test testSetSuperGlobalGatePassesForPrivileged() MockBVM_SETSUPERGLOBAL(5, "value") Assert(MutationSetSuperGlobal = 1) End Test + +; ====================================================================== +; DeleteAbility / DeleteQuest gates -- full privileged. Both take an +; actor/target handle and destroy persistent player state (a learned +; ability, a quest-log entry). They are equivalent-effect bypasses of +; the gated SETABILITYLEVEL / quest-progression mutators: deleting is +; strictly worse than zeroing. The clicker-driven spawn shape sets +; SI\AI = Handle(clicker), so a self-or-priv gate would let +; DeleteAbility(clicker, ...) / DeleteQuest(clicker, ...) through -- +; full RequirePrivileged refuses regardless of how the target relates +; to SI\AI. Zero shipped content-script callers, so the gate breaks +; nothing. +; ====================================================================== + +Test testDeleteAbilityGateBlocksNonPrivileged() + InstallScript(0, 0, 0) + ResetMutationCounters() + MockBVM_DELETEABILITY(999, "Fireball") + Assert(MutationDeleteAbility = 0) + ; Refusal must be audit-logged, not silently dropped. + Assert(Len(LastScriptLog$) > 0) +End Test + +Test testDeleteAbilityGateBlocksStrippingOwnAITarget() + ; Clicker shape: SI\AI = clicker handle (777), Param1 = clicker. + ; A self-or-priv gate would match SI\AI and let the strip through; + ; full-priv must still refuse. + InstallScript(0, 777, 200) + ResetMutationCounters() + MockBVM_DELETEABILITY(777, "Fireball") + Assert(MutationDeleteAbility = 0) +End Test + +Test testDeleteAbilityGatePassesForPrivileged() + InstallScript(1, 0, 0) + ResetMutationCounters() + MockBVM_DELETEABILITY(999, "Fireball") + Assert(MutationDeleteAbility = 1) +End Test + +Test testDeleteQuestGateBlocksNonPrivileged() + InstallScript(0, 0, 0) + ResetMutationCounters() + MockBVM_DELETEQUEST(999, "MainQuest") + Assert(MutationDeleteQuest = 0) + Assert(Len(LastScriptLog$) > 0) +End Test + +Test testDeleteQuestGateBlocksWipingOwnAITarget() + ; Clicker shape: SI\AI = clicker (777), Param1 = clicker. Full-priv + ; refuses where self-or-priv would not. + InstallScript(0, 777, 200) + ResetMutationCounters() + MockBVM_DELETEQUEST(777, "MainQuest") + Assert(MutationDeleteQuest = 0) +End Test + +Test testDeleteQuestGatePassesForPrivileged() + InstallScript(1, 0, 0) + ResetMutationCounters() + MockBVM_DELETEQUEST(999, "MainQuest") + Assert(MutationDeleteQuest = 1) +End Test