in which an escape route is discovered

I was initially something of a skeptic when I first read about the Language Server Protocol. As a huge fan of REPL-based development, I thought it was a step back from the nREPL protocol, which had several years head start on the "editor-agnostic, language-agnostic tooling" approach1. But after using it a while on Clojure and Fennel, I've come around; the features are compelling and worth using.

The biggest difference between nREPL and LSP is their approach of what they do with the code in your project. nREPL is built around the assumption that the main thing you want to do is run your code; interact with it back and forth to see how it works. LSP is built around static analysis: "look but don't touch". For the most part they're complementary; LSP is never going to be able to show you something like test results or tracing, and because of greater overall investment of effort, LSP tends to do a better job of surfacing errors directly in your editor, provided they're compile-time errors.

a nice yellow tree

The difference between running code and just looking at it is pretty big, but some languages blur the distinction. Languages with metaprogramming tend to put up fundamental hurdles to static analysis, because once you allow code to be rewritten by macros, how can you even know what it is you should be analyzing without actually running the macro? And once you've run the macro, you're not doing static analysis any more—a macro could run any arbitrary code, including stealing your SSH keys or wiping your disk.

Here's an example: this code uses a macro which introduces a new local, but without running the macro, the Clojure LSP server can't actually know that, and mistakenly flags it as an error:

(deftest test-all
  (utils/with-system [{:keys [database] &as system} {:start true}]
    (let [user (db/insert-user database (test-data/generate-user))
          request {:user-id (:id user) :endpoint :get-user}]
      (is (= (:username user)
             (:username (grpc/handle system request)))))))

This is pretty annoying when you're coding. There are workarounds to some cases; you can spoon-feed the LSP server to just treat a macro as if for analysis purposes it works just like a built-in; in this case pretending to be let would fix it. But it can't work gracefully out of the box because of this inherent incompatibility.

However, most macros don't actually do anything other than pure transformation of their arguments. It's almost always safe to run them during static analysis, except you don't know for sure2. That's why Fennel's macros can't access the outside world3. By introducing this one limitation, we supercharged our static analysis capabilities to where they're outperforming much more mature and better-funded languages like Clojure.

Unfortunately, up until the 1.4.0 release, there was an issue with the macro sandbox, in particular around its interaction with the metadata system. Fennel allows you to attach metadata to functions and macros, so you can look up things like argument lists and docstrings in the REPL or other dynamic tooling. These get stored in a metadata table in the compiler.

Now of course, the metadata itself is quite safe; it's just strings and lists of symbol names. There's nothing wrong with exposing this inside the compiler sandbox. You want to allow macro definitions to set metadata of macros. The problem is that the data is keyed not on the name of the function4 but on the function itself! So any function which had already been compiled could be run from the macro sandbox, making it easy to escape it.

Anyway, once this was discovered, we quickly released Fennel 1.4.0 with a fix that exposes a write-only proxy table for metadata to the sandbox. Thanks to XeroOl, author of the wonderful fennel-ls language server for discovering this vulnerability and disclosing it.


[1] Actually I still do think that as a protocol it's a big step back from nREPL, but it's not a hill I'm going to die on, because LSP has tons of momentum coming from very deep pockets, and nREPL is almost unheard of outside lisp communities. It's the tale as old as time of the technically superior alternative losing out to the clumsy behemoth. But as a user, you mostly don't care how the protocol is implemented, and the nastiest of the design shortcomings in the protocol seem to have been addressed.

[2] I should note that while I fully agree with the approach of Clojure's LSP server to limit itself to static analysis for safety reasons, this is not a universally-held position. Other language servers don't make the same tradeoffs around safety. In particular I was surprised to learn that rust-analyzer actually runs all macros and specifically documents that it is not safe to run on untrusted codebases. Unfortunately it's unclear whether most of its users are aware of this fact or not. Stay safe out there, folks!

[3] That's a slight oversimplification; it's possible to disable the macro sandbox when it's really necessary, but this is something you wouldn't do unless you knew ahead of time it was safe, and it's certainly not something that the language server would do for you! That said, we have plans to allow you to be more granular and grant specific macros limited access to filesystem capabilities.

[4] Functions in Fennel and Lua don't really have names in a conventional sense, other than the name of the local or table field they're stored in. Since there can be a multitude of these, none of them can be considered canonical or inherent. In a subtle but very real sense, every Lua function is an anonymous function. Stack traces in Lua don't tell you the name of the function as it was defined, but the name used to reference the function when it was called.

« older | 2023-12-31T22:07:31Z | newer »