This is a complex problem where Clojure can really shine. Let's summarise all though points. You want:
- to evaluate some Clojure code coming from user input,
- to make sure it is secured,
- this code needs to run in a context you provide,
- you need to capture all outputs.
1. Evaluating Clojure code from the Java world
As you saw, using RT
from the Java world for this use case quickly shows limitations.
It's simpler to create a Clojure namespace like this one:
(ns interpreter.core)
;; This is unsafe and dangerous, we are going to make it safer in the next steps.
(defn unsafe-eval [code]
(eval (read-string code)))
And to call it from java with this minimal example:
package interpreter;
import clojure.java.api.Clojure;
import clojure.lang.IFn;
class Runner{
public static void main(String[] args){
// Load the `require` function
IFn require = Clojure.var("clojure.core", "require");
// require the clojure namespace
require.invoke(Clojure.read("interpreter.core"));
// load the `unsafe-eval` function we crafted above
IFn unsafe_eval = Clojure.var("interpreter.core", "unsafe-eval");
// execute it
System.out.println("Result: " +unsafe_eval.invoke("(+ 1 2)"));
// => `Result: 3` get printed.
}
}
You can now evaluate any Clojure code from the Java world.
2. Make sure this code is secured
Securing the read phase
As explained in this thread, the safe way to read Clojure code from non-trusted sources is to avoid clojure.core/read-string
and instead, use clojure.edn/read-string
which is designed for this purpose.
Securing the code
You obviously don't want your end-users to be able to access the whole JVM world from your interpreter. Instead, you'd like them to be able to use a set of pre-defined operations you can control.
Since Clojure code is just data, you can walk parsed code and validate it against a spec/schema or more simply using a function:
(ns interpreter.core
(:require [clojure.edn :as edn]
[clojure.walk :as walk]))
;; Users are only allowed to perform operations listed in this set.
(def allowed-operations '#{+ -})
;; Users are also allowed to use lists and numbers
(defn allowed? [x]
(or (list? x) ;; Clojure code is mostly made of lists
(number? x)
(contains? allowed-operations x)))
(defn validate! [parsed-code]
(walk/postwalk (fn [x] (if (allowed? x)
x
(throw (ex-info "Unknown identifier" {:value x}))))
parsed-code))
;; This is safe as long as `allowed-operations` do not list anything sensitive
(defn eval-script [code]
(-> (edn/read-string code) ;; read safely
(validate!) ;; stop on forbidden operations or literals
(eval) ;; run
))
It's up to you to carefully pick safe operations and literals.
3. Providing context
When parsed from a string, non-namespaced symbols will refer to the current namespace. You can then provide functions, values or any other binding in the interpreter namespace, or provide an alias.
(ns interpreter.tools)
(defn cos [x]
(java.lang.Math/cos x))
(defn version []
"Interpreter v0.1")
Adapt interperter.core
accordingly:
(ns interpreter.core
(:require [clojure.edn :as edn]
[clojure.walk :as walk]
[interpreter.tools :as tools]));; import your custom operations
;; Add them to the allowed operations set
(def allowed-operations '#{+ - tools/cos tools/version})
Now tools/cos
and tools/version
functions are available in your interpreter.
4. Capturing all outputs
It would be best to only provide pure functions as available operations, but we don't always control the real world, especially what happens in dependencies.
To make sure you capture STDOUT, you can rewrite eval-script
this way:
(ns interperter.tools)
(defn print-version[]
(println (version)))
(ns interpreter.core
(:require [clojure.edn :as edn]
[clojure.walk :as walk]
[interpreter.tools :as tools])
(:import java.io.StringWriter))
(def allowed-operations '#{+ - do tools/cos tools/version tools/print-version})
(defn eval-script [code]
(let [out (new java.io.StringWriter)]
(binding [*out* out] ;; redirect System.out to the `out` StringWriter
{:result (-> (edn/read-string code) ;; read safely
(validate!) ;; stop on forbidden operations or literals
(eval) ;; run
)
:out (str out)})))
Let's try it from Clojure:
(eval-script "(do (tools/print-version) (tools/cos 1)))")
;; => {:result 0.5403023058681398, :out "Interpreter v0.1\n"}
Let's try it from Java:
java -cp `lein cp` interpreter.Runner "(do (tools/print-version) (tools/cos 1)))"
=> java.lang.RuntimeException: No such namespace: tools
Here is how to fix it:
- Make a function
load-tools!
in interpreter.core
(defn load-tools! []
(require '[interpreter.tools :as tools]))
- Call it once from Java before evaluating your script:
IFn load_tools = Clojure.var("interpreter.core", "load-tools!");
load_tools.invoke();
IFn eval_script = Clojure.var("interpreter.core", "eval-script");
System.out.println("Result: " +eval_script.invoke(args[0]));
Let's try it again:
java -cp `lein cp` interpreter.Runner "(do (tools/print-version) (tools/cos 1))"
=> {:result 0.5403023058681398, :out "Interpreter v0.1\n"}
Here is the full code: https://github.com/ggeoffrey/interpreter-demo