Add ScriptBehavior DSL: data-driven, non-Turing-complete UGC scripting
Implements the DSL that docs/architecture/scripting_sandbox.md recommends
as the primary (and, for now, sufficient) approach in place of running
untrusted GDScript in-process: a fixed set of trigger/condition/action
verbs, expressed as plain JSON data, interpreted by trusted GDScript with
zero ability to reference engine singletons, call arbitrary methods, or
execute code.
- ScriptBehavior (scripts/scripting/script_behavior.gd): parses and
validates rules from JSON/Dictionary data. Every trigger, condition
type/op, and verb is checked against an explicit whitelist at parse
time; malformed or unknown entries are dropped and recorded in
rejected_rules, never partially executed. Carries an optional
content_flags passthrough so this data type isn't hostile to future
moderation-queue integration (docs/architecture/content_moderation.md).
- ScriptInterpreterNode (scripts/scripting/script_interpreter.gd): the
only code that executes rules. Triggers: on_interact (proximity +
"interact" input action), on_enter (edge-triggered Area3D overlap),
on_touch (continuous overlap), on_timer (physics-clocked Timer). Verbs:
give_item, play_sound, move, rotate, spawn_from_catalog, set_timer --
each a fixed, hand-written function; verb dispatch is an explicit
match(), never call()-by-name from data. move/rotate animate via Tween
rather than teleporting.
- PlayerInventory (scripts/scripting/player_inventory.gd): a small
per-player item-count stub for give_item to target, since no real
inventory/entity-state system exists yet (M9 Economy). Attached to
scenes/player.tscn as "Inventory".
- SpawnCatalog (scripts/scripting/spawn_catalog.gd): trusted catalog_id ->
asset_id lookup for spawn_from_catalog, mirroring the AvatarCatalog
data-driven-JSON convention. Rule data only ever supplies a catalog_id
key, never a resource path.
- Wired a real demo ("ScriptedChest") into scenes/worlds/test_level.tscn
with an on_interact rule (give_item + play_sound + rotate), plus an
"interact" input action (project.godot).
- scripts/smoke_test_script_behavior.gd: exercises every trigger type,
every verb, and every condition type against the real interpreter and
the wired demo scene, and proves unknown/malformed/unsafe rules
(unknown verb, unknown trigger, non-scalar smuggled arguments, etc.)
are rejected at parse time and never run -- 50/50 checks pass.
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
class_name ScriptBehavior
|
||||
extends RefCounted
|
||||
## A data-driven, non-Turing-complete behavior attached to a world object.
|
||||
##
|
||||
## This is the DSL described in docs/architecture/scripting_sandbox.md §3.1:
|
||||
## a small, fixed set of trigger/condition/action verbs, expressed as plain
|
||||
## JSON/Dictionary data, with zero ability to reference engine singletons,
|
||||
## call arbitrary methods, or execute code. There is no interpreter to
|
||||
## escape because there is no general execution -- every verb is a reviewed,
|
||||
## hand-written GDScript function in [ScriptInterpreterNode], and every verb
|
||||
## argument here is a plain scalar (String, int, float, bool) or a 3-element
|
||||
## Array of numbers, never a Callable/Object/Script.
|
||||
##
|
||||
## Because the data is exactly this restricted, it plugs into the content
|
||||
## moderation pipeline (docs/architecture/content_moderation.md) the same way
|
||||
## an asset or room does: it's a plain JSON blob that can be hashed, stored,
|
||||
## flagged and reviewed without needing to sandbox code execution to do so.
|
||||
##
|
||||
## ## Format
|
||||
##
|
||||
## ```json
|
||||
## {
|
||||
## "rules": [
|
||||
## {
|
||||
## "trigger": {"type": "on_interact"},
|
||||
## "conditions": [
|
||||
## {"type": "item_count", "item_id": "torch", "op": ">=", "value": 1}
|
||||
## ],
|
||||
## "actions": [
|
||||
## {"verb": "give_item", "item_id": "gold_coin", "count": 3},
|
||||
## {"verb": "play_sound", "sound_id": "chest_open"}
|
||||
## ]
|
||||
## }
|
||||
## ]
|
||||
## }
|
||||
## ```
|
||||
##
|
||||
## Any rule that fails validation (unknown trigger/condition/action, wrong
|
||||
## argument types, out-of-range values, an unrecognized key standing in for
|
||||
## one of the whitelisted ones) is **dropped silently from execution** --
|
||||
## never partially applied, never run "best effort". The rejection reason is
|
||||
## recorded in [member rejected_rules] so callers/tests/moderation tooling
|
||||
## can see *why*, but the interpreter never runs a rule that didn't fully
|
||||
## validate.
|
||||
|
||||
# Sibling scripting scripts are referenced via preload rather than by their
|
||||
# global class_name so this module parses correctly from a bare/cold
|
||||
# compile (matches the convention in scripts/avatar/avatar_loadout.gd --
|
||||
# a headless `godot --script` run doesn't always rebuild the global class
|
||||
# registry; only an editor scan / `--import` reliably does).
|
||||
const ScriptRuleClass := preload("res://scripts/scripting/script_rule.gd")
|
||||
|
||||
const VALID_TRIGGERS: Array[String] = ["on_interact", "on_enter", "on_touch", "on_timer"]
|
||||
const VALID_CONDITION_TYPES: Array[String] = ["item_count", "flag", "timer_active", "timer_remaining"]
|
||||
const VALID_OPS: Array[String] = ["==", "!=", "<", "<=", ">", ">="]
|
||||
const VALID_VERBS: Array[String] = ["give_item", "play_sound", "move", "rotate", "spawn_from_catalog", "set_timer"]
|
||||
const VALID_TARGETS: Array[String] = ["self", "player"]
|
||||
const VALID_AXES: Array[String] = ["x", "y", "z"]
|
||||
|
||||
const MAX_STRING_LEN: int = 64
|
||||
const MAX_ITEM_COUNT: int = 100000
|
||||
const MAX_TIMER_SECONDS: float = 3600.0
|
||||
const MAX_DEGREES: float = 100000.0
|
||||
const MAX_OFFSET_MAGNITUDE: float = 10000.0
|
||||
const DEFAULT_MOVE_DURATION: float = 0.4
|
||||
const MIN_DURATION: float = 0.05
|
||||
const MAX_DURATION: float = 5.0
|
||||
|
||||
## Successfully parsed, executable rules (each a ScriptRuleClass instance --
|
||||
## left as a plain untyped Array since Godot's typed-array element type
|
||||
## can't reliably reference a cross-file preloaded const; see the
|
||||
## preload note above).
|
||||
var rules: Array = []
|
||||
|
||||
## Human-readable reasons every rejected rule (or top-level parse failure)
|
||||
## was dropped. Never empty on a fully-clean input, always non-empty
|
||||
## whenever `rules` is missing entries an author intended.
|
||||
var rejected_rules: Array[String] = []
|
||||
|
||||
## Optional passthrough content-moderation metadata (see
|
||||
## docs/architecture/content_moderation.md §7.2 -- "Each content resource
|
||||
## carries a content_flags metadata field"). Not enforced by anything in
|
||||
## this system yet -- scripts as a moderated content type is still an open
|
||||
## item there -- but the field round-trips through parsing so a future
|
||||
## moderation pipeline has somewhere to read/write it without a format
|
||||
## change. Untrusted data can only ever set this to a plain Dictionary of
|
||||
## scalars; it's metadata, never consulted by the interpreter for control
|
||||
## flow.
|
||||
var content_flags: Dictionary = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Load and parse a ScriptBehavior from a JSON file on disk.
|
||||
static func load_from_file(path: String) -> ScriptBehavior:
|
||||
var behavior := ScriptBehavior.new()
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
behavior.rejected_rules.append(
|
||||
"cannot open '%s' (error %d)" % [path, FileAccess.get_open_error()]
|
||||
)
|
||||
return behavior
|
||||
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed == null or not (parsed is Dictionary):
|
||||
behavior.rejected_rules.append("invalid JSON in '%s'" % path)
|
||||
return behavior
|
||||
|
||||
behavior._parse(parsed as Dictionary)
|
||||
return behavior
|
||||
|
||||
|
||||
## Parse a ScriptBehavior from an already-decoded Dictionary (e.g. embedded
|
||||
## inline in a WorldObject's `properties["script_behavior"]`).
|
||||
static func load_from_dict(data: Dictionary) -> ScriptBehavior:
|
||||
var behavior := ScriptBehavior.new()
|
||||
behavior._parse(data)
|
||||
return behavior
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing / validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _parse(data: Dictionary) -> void:
|
||||
var raw_flags: Variant = data.get("content_flags")
|
||||
if raw_flags is Dictionary:
|
||||
content_flags = raw_flags as Dictionary
|
||||
|
||||
var raw_rules: Variant = data.get("rules")
|
||||
if not (raw_rules is Array):
|
||||
rejected_rules.append("top-level 'rules' key missing or not an array")
|
||||
return
|
||||
|
||||
for entry in (raw_rules as Array):
|
||||
if not (entry is Dictionary):
|
||||
rejected_rules.append("rule entry is not an object: %s" % str(entry))
|
||||
continue
|
||||
var rule := _parse_rule(entry as Dictionary)
|
||||
if rule != null:
|
||||
rules.append(rule)
|
||||
|
||||
|
||||
func _parse_rule(rule_dict: Dictionary) -> ScriptRuleClass:
|
||||
var trigger: Variant = rule_dict.get("trigger")
|
||||
if not (trigger is Dictionary):
|
||||
rejected_rules.append("rule missing a 'trigger' object")
|
||||
return null
|
||||
|
||||
var trigger_type: Variant = (trigger as Dictionary).get("type")
|
||||
if not (trigger_type is String) or not VALID_TRIGGERS.has(trigger_type):
|
||||
rejected_rules.append("rule has unknown/missing trigger type: %s" % str(trigger_type))
|
||||
return null
|
||||
|
||||
var interval: float = 0.0
|
||||
if trigger_type == "on_timer":
|
||||
var raw_interval: Variant = (trigger as Dictionary).get("interval")
|
||||
if not _is_number(raw_interval) or float(raw_interval) <= 0.0 or float(raw_interval) > MAX_TIMER_SECONDS:
|
||||
rejected_rules.append("on_timer rule has invalid 'interval': %s" % str(raw_interval))
|
||||
return null
|
||||
interval = float(raw_interval)
|
||||
|
||||
var conditions: Array[Dictionary] = []
|
||||
var raw_conditions: Variant = rule_dict.get("conditions", [])
|
||||
if not (raw_conditions is Array):
|
||||
rejected_rules.append("rule 'conditions' is not an array")
|
||||
return null
|
||||
for raw_cond in (raw_conditions as Array):
|
||||
if not (raw_cond is Dictionary):
|
||||
rejected_rules.append("condition entry is not an object: %s" % str(raw_cond))
|
||||
return null
|
||||
var cond := _parse_condition(raw_cond as Dictionary)
|
||||
if cond.is_empty():
|
||||
return null
|
||||
conditions.append(cond)
|
||||
|
||||
var actions: Array[Dictionary] = []
|
||||
var raw_actions: Variant = rule_dict.get("actions", [])
|
||||
if not (raw_actions is Array) or raw_actions.is_empty():
|
||||
rejected_rules.append("rule 'actions' is missing, not an array, or empty")
|
||||
return null
|
||||
for raw_action in (raw_actions as Array):
|
||||
if not (raw_action is Dictionary):
|
||||
rejected_rules.append("action entry is not an object: %s" % str(raw_action))
|
||||
return null
|
||||
var action := _parse_action(raw_action as Dictionary)
|
||||
if action.is_empty():
|
||||
return null
|
||||
actions.append(action)
|
||||
|
||||
var rule := ScriptRuleClass.new()
|
||||
rule.trigger_type = trigger_type
|
||||
rule.trigger_interval = interval
|
||||
rule.conditions = conditions
|
||||
rule.actions = actions
|
||||
return rule
|
||||
|
||||
|
||||
## Returns a normalized condition Dictionary, or {} (empty) if invalid --
|
||||
## the empty dict is the "rejected" sentinel since a valid condition always
|
||||
## has at least "type" and "op".
|
||||
func _parse_condition(raw: Dictionary) -> Dictionary:
|
||||
var cond_type: Variant = raw.get("type")
|
||||
if not (cond_type is String) or not VALID_CONDITION_TYPES.has(cond_type):
|
||||
rejected_rules.append("condition has unknown/missing type: %s" % str(cond_type))
|
||||
return {}
|
||||
|
||||
var op: Variant = raw.get("op")
|
||||
if not (op is String) or not VALID_OPS.has(op):
|
||||
rejected_rules.append("condition has unknown/missing op: %s" % str(op))
|
||||
return {}
|
||||
|
||||
var value: Variant = raw.get("value")
|
||||
if not _is_scalar(value):
|
||||
rejected_rules.append("condition 'value' is not a plain scalar: %s" % str(value))
|
||||
return {}
|
||||
|
||||
# Relational ops only make sense against numbers -- reject up front
|
||||
# rather than silently coercing at eval time.
|
||||
if op != "==" and op != "!=" and not _is_number(value):
|
||||
rejected_rules.append("condition op '%s' requires a numeric value" % op)
|
||||
return {}
|
||||
|
||||
match cond_type:
|
||||
"item_count":
|
||||
var item_id: Variant = raw.get("item_id")
|
||||
if not _is_valid_id_string(item_id):
|
||||
rejected_rules.append("item_count condition has invalid item_id: %s" % str(item_id))
|
||||
return {}
|
||||
return {"type": "item_count", "op": op, "value": value, "item_id": item_id}
|
||||
"flag":
|
||||
var name: Variant = raw.get("name")
|
||||
if not _is_valid_id_string(name):
|
||||
rejected_rules.append("flag condition has invalid name: %s" % str(name))
|
||||
return {}
|
||||
return {"type": "flag", "op": op, "value": value, "name": name}
|
||||
"timer_active", "timer_remaining":
|
||||
var timer_name: Variant = raw.get("name")
|
||||
if not _is_valid_id_string(timer_name):
|
||||
rejected_rules.append("%s condition has invalid name: %s" % [cond_type, str(timer_name)])
|
||||
return {}
|
||||
return {"type": cond_type, "op": op, "value": value, "name": timer_name}
|
||||
# Unreachable given the VALID_CONDITION_TYPES check above, but never
|
||||
# fall through to something that looks valid.
|
||||
return {}
|
||||
|
||||
|
||||
## Returns a normalized action Dictionary, or {} (empty) if invalid.
|
||||
func _parse_action(raw: Dictionary) -> Dictionary:
|
||||
var verb: Variant = raw.get("verb")
|
||||
if not (verb is String) or not VALID_VERBS.has(verb):
|
||||
rejected_rules.append("action has unknown/missing verb: %s" % str(verb))
|
||||
return {}
|
||||
|
||||
match verb:
|
||||
"give_item":
|
||||
var item_id: Variant = raw.get("item_id")
|
||||
var count: Variant = raw.get("count")
|
||||
if not _is_valid_id_string(item_id):
|
||||
rejected_rules.append("give_item has invalid item_id: %s" % str(item_id))
|
||||
return {}
|
||||
if not _is_number(count) or int(count) <= 0 or int(count) > MAX_ITEM_COUNT:
|
||||
rejected_rules.append("give_item has invalid count: %s" % str(count))
|
||||
return {}
|
||||
return {"verb": "give_item", "item_id": item_id, "count": int(count)}
|
||||
|
||||
"play_sound":
|
||||
var sound_id: Variant = raw.get("sound_id")
|
||||
if not _is_valid_id_string(sound_id):
|
||||
rejected_rules.append("play_sound has invalid sound_id: %s" % str(sound_id))
|
||||
return {}
|
||||
return {"verb": "play_sound", "sound_id": sound_id}
|
||||
|
||||
"move":
|
||||
var target: Variant = raw.get("target", "self")
|
||||
var offset: Variant = _parse_vector3(raw.get("offset"), MAX_OFFSET_MAGNITUDE)
|
||||
var duration: Variant = _parse_duration(raw.get("duration", DEFAULT_MOVE_DURATION))
|
||||
if not (target is String) or not VALID_TARGETS.has(target):
|
||||
rejected_rules.append("move has invalid target: %s" % str(target))
|
||||
return {}
|
||||
if offset == null:
|
||||
rejected_rules.append("move has invalid offset: %s" % str(raw.get("offset")))
|
||||
return {}
|
||||
if duration == null:
|
||||
rejected_rules.append("move has invalid duration: %s" % str(raw.get("duration")))
|
||||
return {}
|
||||
return {"verb": "move", "target": target, "offset": offset, "duration": duration}
|
||||
|
||||
"rotate":
|
||||
var r_target: Variant = raw.get("target", "self")
|
||||
var degrees: Variant = raw.get("degrees")
|
||||
var axis: Variant = raw.get("axis", "y")
|
||||
var r_duration: Variant = _parse_duration(raw.get("duration", DEFAULT_MOVE_DURATION))
|
||||
if not (r_target is String) or not VALID_TARGETS.has(r_target):
|
||||
rejected_rules.append("rotate has invalid target: %s" % str(r_target))
|
||||
return {}
|
||||
if not _is_number(degrees) or absf(float(degrees)) > MAX_DEGREES:
|
||||
rejected_rules.append("rotate has invalid degrees: %s" % str(degrees))
|
||||
return {}
|
||||
if not (axis is String) or not VALID_AXES.has(axis):
|
||||
rejected_rules.append("rotate has invalid axis: %s" % str(axis))
|
||||
return {}
|
||||
if r_duration == null:
|
||||
rejected_rules.append("rotate has invalid duration: %s" % str(raw.get("duration")))
|
||||
return {}
|
||||
return {"verb": "rotate", "target": r_target, "degrees": float(degrees), "axis": axis, "duration": r_duration}
|
||||
|
||||
"spawn_from_catalog":
|
||||
var catalog_id: Variant = raw.get("catalog_id")
|
||||
var position: Variant = _parse_vector3(raw.get("position"), MAX_OFFSET_MAGNITUDE)
|
||||
if not _is_valid_id_string(catalog_id):
|
||||
rejected_rules.append("spawn_from_catalog has invalid catalog_id: %s" % str(catalog_id))
|
||||
return {}
|
||||
if position == null:
|
||||
rejected_rules.append("spawn_from_catalog has invalid position: %s" % str(raw.get("position")))
|
||||
return {}
|
||||
return {"verb": "spawn_from_catalog", "catalog_id": catalog_id, "position": position}
|
||||
|
||||
"set_timer":
|
||||
var timer_name: Variant = raw.get("name")
|
||||
var seconds: Variant = raw.get("seconds")
|
||||
if not _is_valid_id_string(timer_name):
|
||||
rejected_rules.append("set_timer has invalid name: %s" % str(timer_name))
|
||||
return {}
|
||||
if not _is_number(seconds) or float(seconds) <= 0.0 or float(seconds) > MAX_TIMER_SECONDS:
|
||||
rejected_rules.append("set_timer has invalid seconds: %s" % str(seconds))
|
||||
return {}
|
||||
return {"verb": "set_timer", "name": timer_name, "seconds": float(seconds)}
|
||||
|
||||
# Unreachable given the VALID_VERBS check above.
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scalar validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _is_number(v: Variant) -> bool:
|
||||
return v is int or v is float
|
||||
|
||||
|
||||
## A "plain scalar" -- the only shapes a condition/action value is ever
|
||||
## allowed to be. Deliberately excludes Dictionary, Array, Object, Callable,
|
||||
## Node, Resource -- there is no representable way for UGC data to hand the
|
||||
## interpreter anything but a bare String/int/float/bool.
|
||||
func _is_scalar(v: Variant) -> bool:
|
||||
return v is String or v is int or v is float or v is bool
|
||||
|
||||
|
||||
## Identifiers (item ids, sound ids, catalog ids, flag/timer names) are
|
||||
## always plain, short, non-empty strings -- never used as a path, never
|
||||
## passed to load()/call() directly, only ever looked up as a Dictionary
|
||||
## key against a trusted, code-defined table.
|
||||
func _is_valid_id_string(v: Variant) -> bool:
|
||||
if not (v is String):
|
||||
return false
|
||||
var s: String = v
|
||||
return not s.is_empty() and s.length() <= MAX_STRING_LEN
|
||||
|
||||
|
||||
func _parse_duration(raw: Variant) -> Variant:
|
||||
if not _is_number(raw):
|
||||
return null
|
||||
var d: float = clampf(float(raw), MIN_DURATION, MAX_DURATION)
|
||||
return d
|
||||
|
||||
|
||||
## Parses a JSON array of exactly 3 numbers into a Vector3. Returns null
|
||||
## (never a partially-applied Vector3) on anything else.
|
||||
func _parse_vector3(raw: Variant, max_component: float) -> Variant:
|
||||
if not (raw is Array) or (raw as Array).size() != 3:
|
||||
return null
|
||||
var comps: Array = []
|
||||
for c in (raw as Array):
|
||||
if not _is_number(c) or absf(float(c)) > max_component:
|
||||
return null
|
||||
comps.append(float(c))
|
||||
return Vector3(comps[0], comps[1], comps[2])
|
||||
Reference in New Issue
Block a user