Files
architect-bot 4da8687f2c Document GDScript sandboxing findings and recommendation
Verified all three options from the task against Godot 4.6.2 directly
(godot --headless), rather than trusting the probe scripts' own printed
output from earlier sessions:
- ScriptServer doesn't exist as a class; no disable_os/disable_file_access/
  disable_network project settings exist either. Nothing to configure.
- SubViewport isolates rendering/input only, not script capabilities — a
  script inside one has identical OS/FileAccess/network access.
- Expression is not safe by default (can still reach unsafe methods through
  a base instance); blocklisting dangerous substrings is trivially bypassed.

Recommendation: don't sandbox GDScript in-process (nothing to sandbox
with) — use a narrow non-Turing-complete content DSL for the common case,
and OS-level process isolation only if genuinely general scripting turns
out to be required.
2026-08-17 23:43:48 +00:00

8.1 KiB

GDScript Sandboxing for User-Written Scripts

Status: Proposed Owner: Platform team Related: Content moderation & age verification (scripts are one of the three moderated content types), avatar component system

1. Overview

This document evaluates how to safely run user-written scripts as part of UGC (user-generated content) in agp-game, and recommends an approach.

Bottom line: Godot 4 has no built-in security sandbox for GDScript, and none of the three options named in the original task hold up under testing. A script running anywhere inside the Godot process — however it's loaded, parented, or wrapped — runs with full engine privileges: arbitrary process execution, full filesystem read/write, and raw network access. Sandboxing has to happen outside the scripting language, not through an in-engine restriction mechanism, because no such mechanism exists.

This was verified directly against Godot 4.6.2 (godot --headless, evidence in sandbox_probe/ on this branch) rather than assumed from documentation, some of which (see §2.1) describes APIs that don't actually exist in this version.

2. Options Evaluated

2.1 Godot's built-in "ScriptServer" restrictions — does not exist

There is no ScriptServer class in Godot 4.6.2:

ClassDB.class_exists("ScriptServer")  →  false

There's also no project setting to disable engine subsystems for scripts — none of application/run/disable_os, disable_file_access, or disable_network exist as settings, and ProjectSettings.get_property_list() returns nothing matching "sandbox", "restrict", or "security". Any plan that assumes a flag like this exists needs to be dropped; it was likely conflated with sandboxing features from a different engine/scripting ecosystem, or with export template stripping (which controls what ships in a build, not what a script can call at runtime).

Directly probed capabilities of an ordinary GDScript running as a normal node script, no special setup:

Capability Available to any script?
OS.execute(...) — run arbitrary processes ✅ Yes
FileAccess.open(...) — read/write any path the OS user can reach ✅ Yes (read /etc/hostname, write /tmp/... both succeeded)
TCPServer / HTTPClient / HTTPRequest / PacketPeerUDP / WebSocketPeer ✅ All instantiate and work
ClassDB.instantiate(...) — construct any engine class by name ✅ Yes
Loading and running a second script from source text at runtime (GDScript.new() + .reload()) ✅ Yes

Verdict: rejected. There is nothing to configure here — the restriction surface this option assumed doesn't exist in the engine.

2.2 Isolated SubViewport with a limited API — doesn't isolate scripts

SubViewport is a rendering and input isolation primitive (it gives a scene subtree its own render target and can gate input propagation) — it is not a security or scripting boundary. A script attached to any node inside a SubViewport still runs in the same process, on the same GDScript VM, with the exact same access to OS, FileAccess, networking classes, and ClassDB as a script anywhere else in the tree. Nothing about being "inside" a SubViewport changes what API calls are reachable from a script.

Verdict: rejected. This solves a rendering/UI-composition problem, not the security problem the task is asking about. Worth keeping in mind for other purposes (e.g. isolated preview rendering of user avatars), but it provides zero script sandboxing.

2.3 Custom GDScript interpreter with an allowlist — right idea, wrong layer for "custom interpreter"

Two ways to read this option, tested separately:

(a) Write/embed a separate GDScript parser+interpreter. Not practical — this means re-implementing (and then indefinitely maintaining, in lockstep with every Godot release) a full language interpreter outside the engine. Far more engineering than the problem justifies.

(b) Use Godot's built-in Expression class as a restricted subset. Expression looks like a natural allowlist mechanism — it only evaluates a single expression, no control flow, no arbitrary statements. But it isn't safe by default: it can still reach through to unsafe engine methods if you aren't careful about what object you hand it as a base instance:

var expr = Expression.new()
expr.parse("get_environment('HOME')", ["OS"])
expr.execute([], OS)   # → "/home/daddy"  (successfully called OS.get_environment)

So Expression is a viable building block only if the host never passes a base instance (or arguments) that exposes anything sensitive — i.e. the allowlisting work still has to happen at the call site, Expression itself enforces none of it.

Static text pattern-matching (blocklisting dangerous substrings like "OS.execute" before parsing) was also probed and is not a real defense — it's trivially defeated by string concatenation, call(), Callable, or any other indirection, and produces false confidence.

Verdict: partially viable, but only as a foundation, not a solution on its own. A hand-built, narrow, data-driven DSL (not full GDScript, not raw Expression) with an explicit allowlist of safe operations and no ability to reference engine singletons or arbitrary objects is the only version of "custom interpreter" that's actually safe. This is real engineering effort, not a config flag.

3. Recommendation

Don't run untrusted GDScript in-process at all. Given none of the three in-engine options provide a real boundary, the two approaches actually worth building are, in order of recommended priority:

  1. Primary: a narrow, non-Turing-complete content DSL, not general scripting. Most UGC scripting needs in a game like this (custom interactions, simple triggers, item behaviors) don't need a general programming language — they need a small set of declarative verbs/conditions (e.g. on_interact: give_item("torch"), on_enter: play_sound("chime")). Expressed as data (JSON/Resource, not code) and interpreted by trusted, hand-written GDScript on the engine side. This has zero attack surface by construction — there's no interpreter to escape because there's no general execution, just a fixed, reviewed set of verbs. This is almost certainly sufficient for the actual UGC use cases in this game and should be scoped against real feature requirements before assuming more is needed.

  2. If genuinely general scripting becomes a hard requirement, isolate it at the OS level, not the engine level: run untrusted scripts in a separate process (a stripped-down Godot instance or a dedicated interpreter) under OS sandboxing (a restricted user account, a container, or firejail/bwrap/seccomp), communicating with the main game over a narrow IPC/RPC boundary that only exposes intentionally-safe operations. This is the only approach that provides a real security boundary for arbitrary code, because it doesn't depend on the scripting language cooperating — the OS enforces it. Substantially more infrastructure than option 1; only worth it if option 1's DSL genuinely can't express what's needed.

Do not attempt in-process GDScript sandboxing via SubViewport, ScriptServer, or ad hoc blocklisting — all three were directly tested and none provide a real boundary (§2).

4. Open Questions

  • What do actual planned UGC scripts need to do? (Trigger conditions, simple item/state effects, dialogue branching?) This determines whether the DSL in §3.1 is sufficient or whether §3.2's process isolation is needed. Needs a concrete feature list before implementation starts.
  • If §3.2 is needed later, does it run per-user-session or shared/pooled? Affects resource cost and isolation guarantees.
  • Interaction with the content moderation pipeline (content_moderation.md): scripts as a moderated content type still need a moderation flag path regardless of which execution model is chosen — a DSL makes static analysis of what a script can possibly do far easier than it would be for general code, which is an additional point in its favor.