Skip to content

Instance methods level control#1027

Open
jeroenvandijk wants to merge 19 commits into
babashka:masterfrom
jeroenvandijk:instance-methods-control-exp
Open

Instance methods level control#1027
jeroenvandijk wants to merge 19 commits into
babashka:masterfrom
jeroenvandijk:instance-methods-control-exp

Conversation

@jeroenvandijk

@jeroenvandijk jeroenvandijk commented Feb 21, 2026

Copy link
Copy Markdown
Contributor

Please answer the following questions and leave the below in as part of your PR.


Sci already offers control over what functions can and can't be called, and each function can get a custom implementation. For interop there is no such feature yet. Right now, you can either call an instance method as per the original implementation or you can't (configured via :classes). Ideally, there would be a middle ground where you can offer an alternative implementation to an otherwise potentially harmful method, or in some cases deny certain methods entirely.

This is a first take at adding method level based control to Sci. It should allow to give access to functionality of configured classes while blocking other undesirable functionality, e.g. think access control of a filesystem.

The implementation in this PR allows to add a custom implementation of a method (as a function) and other methods can be denied.

E.g.

     (testing "single method override"
       (let [config {:classes {'java.lang.String
                               {:class java.lang.String
                                :instance-methods {'toString
                                                   (fn [_s]
                                                     :dude)}}}}]
         (is (= :dude (tu/eval* "(.toString \"your name\")" config)))
         (is (= 9 (tu/eval* "(.length \"your name\")" config)))))

     (testing "single method allowed"
       (let [config {:classes {'java.lang.String
                               {:class java.lang.String
                                :instance-methods {:deny true
                                                   'toString
                                                   (fn [_s]
                                                     :dude)}}}}]
         (is (= :dude (tu/eval* "(.toString \"your name\")" config)))
         (is (thrown-with-msg? Exception #"allowed"
                               (tu/eval* "(.length \"your name\")" config)))))))

I believe the overhead per method invocation of this feature to traditional paths is limited to:

(if-let [instance-methods (:instance-methods class-config)]
  <NEW-CODE>
  <OLD-CODE>)

The option :allow is checked first and if true, the rest is ignored and interop is applied directly (like before), so no overhead in that case.

The option :deny in :instance-methods could be extended to static methods, static fields and instance fields.

(some-> x :field) expands to (if (nil? x) nil (:field x)) so adds some unnecessary overhead when fields are never nil
Does not interfere with :allow :all

E.g.

(sci/eval-string "(.toString \"your name\")"
                 {:classes {'java.lang.String 
                            {:class java.lang.String
                             :instance-methods {'toString
                                                (fn [_s] 
                                                  :dude)}}}})
@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

This approach also works nicely with the new 1.12+ way of referring to an instance method:

'java.io.BufferedReader {:class java.io.BufferedReader 
                         :instance-methods 
                         {:deny true
                          'close java.io.BufferedReader/.close}}

@jeroenvandijk

jeroenvandijk commented Mar 10, 2026

Copy link
Copy Markdown
Contributor Author
Feedback from Claude

Here's my review of PR #1027:

  PR #1027: Instance methods level control

  Concept: Adds per-method control for instance method interop — you can override individual methods with custom functions, or deny all non-listed methods. This is a useful security feature for sandboxing.

  Issues I see:

  1. Analyzer change (analyzer.cljc) is a refactor with a subtle bug

  The some-> rewrite changes behavior. Original:
  (some-> ctx :env deref :class->opts :static-methods (get ...) (get ...))
  If (-> ctx :env deref) returns nil, some-> short-circuits. The new code:
  (-> ctx :env deref :class->opts (some-> :static-methods (get ...) (get ...)))
  Now (-> ctx :env deref) is called unconditionally (which is fine — it won't be nil in practice), but deref on nil would NPE. The original some-> guarded against that. In practice :env is always present, so this is unlikely to cause issues, but
   the change is unrelated to the PR's purpose and introduces unnecessary risk. I'd drop this hunk.

  2. normal-interop closure allocated on every invocation

  In the evaluator, normal-interop is defined as a fn inside the let — this creates a new closure object on every method call. This is a hot path (every instance method invocation goes through here). The original code just had the expressions
  inline. Consider inlining the two call sites or using a macro to avoid the allocation.

  3. args are not evaluated before being passed to the custom function

  When a custom :instance-methods function is found, the PR does:
  (apply f instance-expr* args)
  But args at this point are still SCI nodes (unevaluated). The existing invoke-instance-method evaluates them internally. The custom function will receive node objects, not the actual argument values. This is a correctness bug — the tests may
  pass because .toString takes no args, but any method override with arguments would break.

  4. Method arity mismatch for overrides

  The custom function receives (instance-expr* ...args), but the PR comment/test asks "should toString also receive the class like :static-methods?" This API inconsistency should be decided before merge.

  5. :deny key lives inside the :instance-methods map alongside symbol keys

  Mixing config keys (:deny) with data keys (method symbols) in the same map is a bit awkward. A symbol can never collide with :deny, so it works, but a cleaner design might be:
  {:instance-methods {:deny-unlisted true
                      :overrides {'toString (fn [s] ...)}}}
  Or alternatively, make :instance-methods a map of {symbol fn-or-:deny} with a separate :deny-unlisted-methods key on the class config.

  6. No support for static methods, fields

  The PR description acknowledges this. It's fine for a first iteration, but the design should anticipate extending to static methods/fields.

  7. Tests use tu/eval* with when-not tu/native?

  The tests correctly guard against native mode, but per the CLAUDE.md guidelines, tests that pass custom :classes opts should use sci/eval-string directly instead of tu/eval* — this makes the intent clearer and avoids the native-mode shelling.

  Summary

  The concept is solid and useful for sandboxing. The critical issue is #3 (args not evaluated) — this will cause runtime failures for any overridden method that takes arguments. The performance concern (#2) should also be addressed given this
  is a hot path. I'd suggest the author fix the args evaluation, inline the normal-interop fn, and drop the unrelated analyzer refactor.

:black_circle_for_record: Looking at the hot path in eval-instance-method-invocation:

  1. Closure allocation on every call — The normal-interop fn is the main concern. It's created on every instance method invocation regardless of which branch is taken. On the JVM, the JIT may optimize this via escape analysis, but it's not
  guaranteed and adds GC pressure at minimum.

  2. For the :allow :all path (babashka's default config) — the overhead is minimal beyond the closure:
  - Master: (get class->opts :allow) → truthy → set target-class → call interop inline
  - PR: (get class->opts :allow) → truthy → call (normal-interop instance-class)

  Same number of lookups, just the extra closure indirection.

  3. For the per-class config path (no :allow :all) — there's one additional map lookup per call:
  - Master: (get class->opts instance-class-symbol) → truthy → call interop
  - PR: (get class->opts instance-class-symbol) → (:instance-methods class-config) → then interop

  This extra :instance-methods lookup happens even when the feature isn't used.

  Bottom line: The closure is the only real concern. The extra map lookups are negligible. You could eliminate the closure by inlining normal-interop at its 3 call sites (it's only 3 lines). For a hot-path function in an interpreter loop,
  avoiding unnecessary allocations matters — especially on GraalVM native-image where JIT escape analysis isn't available.

  • I've reverted the optimization that Claude claimed could have a subtle bug. It is not needed for this PR and could be done seperately (if it is not a bug)
  • I've reverted the :deny addition. Technically with this change it is possible to deny methods by overriding every method of a class. A :deny like option can be done as a seperate PR.
  • I've added a test for point 4) of Claude's feedback and corrected the implementation
  • The tests now use sci/eval-string instead of test.util/eval*
  • I've inlined the interop calls. I can make it more dry, similar to the original change, but I think that code would be les efficient.

I'm still uncertain whether the custom functions passed via :instance-methods need to receive an extra argument like in :static-methods:

:static-methods {'forName (fn [_Class _forName] :dude)}

I don't know why this _Class argument is required for :static-methods, but maybe there is something I'm missing.

@borkdude

Copy link
Copy Markdown
Collaborator

I don't know why this _Class argument is required for :static-methods, but maybe there is something I'm missing.

Because a static method is called on a class. But instance methods we don't need this.

Some additional feedback:

  • (symbol method-str) allocates on every call

At evaluator.cljc:162, (symbol method-str) creates a new symbol on every method invocation for the lookup. Since method-str is known at analysis time, this symbol could be pre-computed and stored alongside the other analyzed data to avoid per-call allocation in a hot path.

  • Some changes affect CLJS too but there's no CLJS tests

@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

This work could also help block forever running code like

(.pow (biginteger 10) 10000000) 

From #1038 (comment)

@jeroenvandijk

jeroenvandijk commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author
  • I've moved the symbol generation to the analyzer. Note that in the get-from-type function there is also symbol creation, but that might be a different scenario.

To avoid surprises (e.g. because of typos) I only allow interop of instance methods when the :instance-methods config is absent. Previously it would fallback on to interop when a method call was not found in the :instance-methods config. I think this is an unsafe default.

I will look into the (failing) cljs tests now

@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

Here is an example script using this code that limits execution time for java.math.BigInteger/.pow

;; Try the following
;;
;;   deps-try https://github.com/jeroenvandijk/sci instance-methods-control-exp

(require '[sci.core :as sci]
         '[sci.ctx-store :as store]
         '[sci.interrupt :as interrupt])

(defn pow'
  [x exponent]
  (let [interrupt-fn (or (interrupt/get-interrupt-fn (store/get-ctx)) (constantly nil))]
    (when (neg? exponent)
      (throw (ArithmeticException. "negative exponent")))
    (loop [e exponent
           base (bigint x)
           acc  1N]
      (interrupt-fn)
      (if (zero? e)
        acc
        (recur (quot e 2)
               (*' base base)
               (if (odd? e)
                 (*' acc base)
                 acc))))))

(defn time-limit [ms]
  (let [deadline (+ (System/nanoTime) (.toNanos java.util.concurrent.TimeUnit/MILLISECONDS ms))]
    (fn []
      (when (> (System/nanoTime) deadline)
        (interrupt/interrupt!)))))

(time
  (try
    (sci/eval-string "(.pow (biginteger 10) 10000000)"
                     {:interrupt-fn (time-limit 100)
                      :classes {'java.math.BigInteger {:instance-methods {'pow pow'}}}})
    (catch Exception e
      (println (ex-message e)))))

;; Interrupted
;; "Elapsed time: 139.687542 msecs"
;; => nil
Details

@jeroenvandijk jeroenvandijk changed the title WIP Instance methods level control Instance methods level control Jun 18, 2026
@borkdude

Copy link
Copy Markdown
Collaborator

Looks really clean to me. Before I did into the code: are there any costs for existing SCI users when NOT using the extra feature?

@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

I think there is effectively one extra lookup and check around (:instance-methods class-config) when there is config for the requested class. I will do some benchmarks to be sure what the effect is.

Also I am wondering if not more things can move to the analyzer phase.

@borkdude

Copy link
Copy Markdown
Collaborator

If that check is only at analysis time and not at runtime, then that's fine

@jeroenvandijk

jeroenvandijk commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

The check is at runtime so I will have a look how I can change that. It is a bit hard to do a good benchmark, but it seems there is some overhead when the feature is not being used (see test results below).

Test results
deps-try https://github.com/babashka/sci 9fa8e46 criterium/criterium "0.4.6"

vs

deps-try https://github.com/jeroenvandijk/sci instance-methods-control-exp criterium/criterium "0.4.6"

Test code

(require '[sci.core :as sci] 
         '[criterium.core :refer :all])

;; To minimize overhead let Sci generate a closure that we can call directly with Criterium
(let [f (sci/eval-string "(fn [] (.toString true))" {:classes {'java.lang.Boolean {}}})] 
  (bench (f)))

master (9fa8e46)

Evaluation count : 322651260 in 60 samples of 5377521 calls.
nil
             Execution time mean : 184,356335 ns
    Execution time std-deviation : 1,989210 ns
   Execution time lower quantile : 182,792917 ns ( 2,5%)
   Execution time upper quantile : 185,113294 ns (97,5%)
                   Overhead used : 2,185758 ns

Found 1 outliers in 60 samples (1,6667 %)
	low-severe	 1 (1,6667 %)
 Variance from outliers : 1,6389 % Variance is slightly inflated by outliers

io.github.jeroenvandijk/sci (66121ce)

Evaluation count : 306853560 in 60 samples of 5114226 calls.
nil
             Execution time mean : 194,259560 ns
    Execution time std-deviation : 2,205282 ns
   Execution time lower quantile : 192,644374 ns ( 2,5%)
   Execution time upper quantile : 194,994132 ns (97,5%)
                   Overhead used : 2,131564 ns

Found 1 outliers in 60 samples (1,6667 %)
	low-severe	 1 (1,6667 %)
 Variance from outliers : 1,6389 % Variance is slightly inflated by outliers

@borkdude

Copy link
Copy Markdown
Collaborator

If you can please move the check to analysis time

@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

I don't think the check can be moved to analysis time because the instance class is only known at runtime. The best I can think of is that we do something with method-cache in interop.

E.g. this cache could be prepopulated with the options given in the context. I think this only needs a small wrapper change to make it work for both functions and methods.

For cljs there is no caching mechanism we can reuse, so the new code would still have overhead compared to the current approach.

Having said that, it could be an option to move the permission checks to the interop part and cache this as well. I'm guessing it would be the same as the current implementation or maybe even a bit faster because the current permission checks would only be executed once. I can try this route as well if you think it could be ok.

@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

Here is a POC for moving the permissions checks to interop as I mentioned as alternative above 9fa8e46...jeroenvandijk:sci:instance-methods-control-v3

Note that I didn't implement the caching yet because i need to get all the arguments right, but this would be more or less the structure I believe. The tests pass for both jvm and node, but I have a feeling maybe there is some missing coverage as I didn't implement permissions for the node part.

TLDR; this direction is probably too big of a chance.

@borkdude

Copy link
Copy Markdown
Collaborator

I'll take a look at this soon-ish. The most important bit is to get the API right, the rest will probably work out somehow

@borkdude

Copy link
Copy Markdown
Collaborator

API + tests I mean

@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

I just realized I've missed the case where a method is overriden for a public class (implementation will make the code less clean).

(testing "method override for child instances"
       (is (= #{:b} (tu/eval* "(.keySet {:a 1})"
                              {:classes {'java.util.Map {:instance-methods {'keySet (fn [_m] #{:b})}}
                                         :public-class (fn [o]
                                                         (when (instance? java.util.Map o) java.util.Map))}}))))

@jeroenvandijk

Copy link
Copy Markdown
Contributor Author

I've added an implementation to support the above case. But now I'm thinking what to do in this case:

 (is (= #{:b} (tu/eval* "(.keySet {:a 1})"
                        {:classes {'clojure.lang.PersistentArrayMap {:instance-methods {'toString (fn [_m] #{:c})}}
                                   'java.util.Map {:instance-methods {'keySet (fn [_m] #{:b})}}
                         :public-class (fn [o]
                                         (when (instance? java.util.Map o) java.util.Map))}})))

With the current implementation it will throw because :instance-methods is used as a whitelist and .keySet isn't listed for clojure.lang.PersistentArrayMap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants