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.
This commit is contained in:
architect-bot
2026-08-17 23:43:48 +00:00
parent 119cd9400a
commit 4da8687f2c
2 changed files with 165 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
# 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:
```gdscript
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.