Security 1: Restricting What Can Run
The problem
A model file is just text — XML, JSON, or .sgm — and it’s easy to end up running one you didn’t author yourself: a teammate’s, a customer’s, something a model-building agent produced, or even one downloaded from a model sharing site online. A handful of components in this system name things by string: a class name for a custom component, a file path, a database URL, an HTTP endpoint.
If nothing stood between “the model file says so” and “the process does it,” a model file would be an arbitrary-code and arbitrary-network-access vector, not a data description. Two independent controls prevent that — one for what code is allowed to run at all, one for what already-running code is allowed to touch.
What can run: only what’s already there
<definitions><custom class="...">, Convert’s class="", and dataset.random’s class name all do the same thing under the hood: they use Java’s dynamic class loading (Class.forName) to create objects of the named type. That’s a lookup against classes the JVM already knows about from how it was launched, which means the class has to exist on the CLASSPATH the application uses.
A model trying to use com.evil.Backdoor gets a plain ClassNotFoundException, the same as typing a class that doesn’t exist anywhere, because nothing in this path downloads, compiles, or writes a .class file from the model text. The only way an actually-dangerous class ends up reachable this way is if the owner of the application explicitly adds the external JAR file to the CLASSPATH before running. This is a supply-chain/deployment question for the operations team, not something a model file can cause by itself.
For the Free tier, it’s even simpler. Dynamic class loading isn’t available at all in SpiceGrinder Free, so it’s impossible to inject a malicious class this way.
What SpiceGrinder is allowed to touch: opt-in, not on by default
Being on CLASSPATH only answers “can this code exist here?” The next question to ask is “what can my model read or affect?”
The default answer SpiceGrinder uses in any such case is: nothing that isn’t strictly local, unless you explicitly allow it.
Three components (Database, FlatFile, and ServiceCall) can still reach outside the model itself once they’re running. Each of them starts as “deny all” and has an allowlist that starts empty — meaning the capability is fully off until an operator turns it on, not “unrestricted until you lock it down”:
# ~/.spicegrinder/preferences.properties
flatFile.path.allowlist=/data/shared/
database.url.allowlist=jdbc:h2:
database.allowQuery=false
service.url.allowlist=http://localhost:
FlatFile’s absolute paths need a prefix match here; a relative path always works, because it can only ever reach somewhere the deploying process’s own working directory already exposes — the deployer controls that regardless of who wrote the model. The match itself is boundary-aware, not a naive string prefix: an allowlist entry of /data/shared doesn’t accidentally also permit /data/shared_secrets just because the characters happen to line up, and it checks the real, symlink-resolved path — not the raw text in the model file, which a .. or a symlink could otherwise walk somewhere the prefix never intended.
Database has two separate gates, not one: the connection URL itself needs a prefix match (database.url.allowlist), and a free-form query= needs a second, independent flag (database.allowQuery=true) — a plain table="..." read (a fixed SELECT * FROM <table>) doesn’t need that second gate, only arbitrary SQL text does.
ServiceCall follows the same one-layer pattern as FlatFile: a URL prefix match against service.url.allowlist, nothing reachable until an entry is added.
All three act the same deliberate way: an empty allowlist doesn’t mean “unrestricted,” it means “disabled.” Turning on file access to /data/shared/ says nothing about database or service access — each capability is opted into individually, by the owner of the process, never by the model file itself.
Loopback by default
ModelServiceApp binds 127.0.0.1 (loopback) unless told otherwise — reachable only from the same machine, not the network. Passing --bind 0.0.0.0 (or anything else non-default) always prints a startup warning, because that’s a real change in who can reach it, worded differently depending on whether auth.token is set — a bearer token doesn’t turn a plain HTTP listener into a protected one on its own, so the warning says so either way rather than letting a token feel like more protection than it is.
Conclusion
Put together, “restricting what can run” is really two separate, both deny-by-default questions: what code is allowed to exist in the process at all (answered once, at launch, by the classpath), and what already-running code is allowed to reach (answered per-capability, by an allowlist that starts empty). A model file can’t override either method — it can only use what the deploying process already decided to allow.