Using LLM tools in Emacs

What can an Emacs user do nowadays, when LLMs are unavoidable?

Is it necessary to use the LLMs? Not strictly, but there are reasons why they may be needed:

  • LLMs are sometimes actually useful.
  • Some employers require using LLMs, whether it makes sense for the given tasks or not (those employers always think it does).

And why did I bother to write this blog post? Because I’ve found out setting up Emacs for use with LLMs is non-trivial:

  • The LLM tools are typically written using LLMs, resulting in documentation that is all but an easy to use manual.
  • Even when the documentation is clear, it’s difficult to maintain up-to-date information about all the LLM providers.
  • There’s little interest in (real) security of running AI agents. Basic sandboxing can be achieved using Podman and other tools but it’s not easy to set up it.

This blog post describes some outputs of my journey of setting up LLM tools in Emacs. Don’t worry, it’s written by a human, no LLMs involved.

How about LLM providers?

Different Emacs LLM tools support different providers. For my personal use, I play with Mistral, because it’s the only widely supported European provider. At my job, I use whatever is available and permitted to use there.

I don’t have a sufficiently powerful computer to run models reasonably locally but if it is still necessary, RamaLama makes a good job running them.

If a provider is not supported directly by a given Emacs tool or its backend, it may be possible to access it through a proxy, such as LiteLLM. This may be also good for security. More on this later.

Even if the provider is supported directly, it may not be straightforward to configure it. I give some examples below.

General purpose LLM use

This one is relatively easy. gptel does a pretty good job. Unless you want to play with agents, there are little security risks. Just talk to the model provider using the API. In case of local models, RamaLama works fine.

Here’s an example gptel configuration for RamaLama locally running IBM Granite:

(setq gptel-model 'granite4
      gptel-backend (gptel-make-openai "ramalama"
                      :stream t
                      :protocol "http"
                      :host "127.0.0.1:8080"
                      :models '(granite4)))

An example configuration for Mistral API:

(setq gptel-model 'mistral-small
      gptel-backend (gptel-make-openai "Mistral"
                      :host "api.mistral.ai"
                      :endpoint "/v1/chat/completions"
                      :protocol "https"
                      :key 'gptel-api-key-from-auth-source
                      :models '("mistral-small"
                                "mistral-large"
                                "mistral-medium")))

This requires a matching entry in ~/.authinfo:

machine api.mistral.ai login apikey password THE-API-PASSWORD

Code completion

Among the several Emacs packages that offer LLM-based code completion, I stick with Minuet. First, it works, and second, I like how it offers several choices on each completion, so one can select the reasonable one (if any) easily.

However, the provided instructions for Mistral don’t work. I ended up with this version, which works for me currently:

(use-package minuet
  :custom
  (minuet-provider 'openai-fim-compatible)
  :config
  (setq minuet-openai-fim-compatible-options
        '(:model "codestral-latest"
          :end-point "https://api.mistral.ai/v1/fim/completions"
          :api-key "MISTRAL_API_KEY"
          :name "Mistral"
          :template (:prompt minuet--default-fim-prompt-function
                     :suffix minuet--default-fim-suffix-function)
          :transform ()
          :get-text-fn minuet--openai-get-text-fn
          :optional nil)))

Code development

From the many packages available, I selected Editor Code Assistant (ECA) + ECA Emacs. It works, has lots of features and ECA is developed with Emacs in mind, which should make some things easier and avoid sudden breakages.

My second choice would be Emacs Agent Shell. It’s quite different, for better or worse, depending on your taste. But it depends on unrelated ACP backends like OpenCode or goose, which is somewhat risky.

ECA has documentation on its configuration with examples. Many things are described well enough but not everything is clear or working as I’d expect. Especially when it comes to sandboxing.

Isolation

It starts to be really complicated here. Current LLMs are unable to produce working code with pure LLM functionality, they need to actively gather information from the file system and to get feedback from running compilers, tests and other tools. Which means we should care about security.

Unfortunately, the LLM backends don’t help much in this area. And many of the hundreds sandboxing solutions that isolate the LLM backend and what it does are insufficient and help only partially. So unless one is comfortable with watching the agentic sessions all the time and approving almost everything manually, things get tricky.

I think the requirements are simple:

  • Credentials cannot be stolen, most notably the ones to the LLM APIs.
  • The agents cannot cause damage on the local computer, like making file changes that cannot be reverted easily.
  • All network access should be under control.

But their implementation is not that easy. I didn’t want to create another LLM sandboxing tool so I decided to stick with Podman.

Basic container setup

In order to fulfil the requirements, the container running an LLM backend must be isolated from most of the host file system, it cannot have access to the external network and any changes within it should be harmless. A way to do it is:

  • ECA stuff must be mounted into the container read-only, with the exception of the cache.
  • Source code directories can be mounted read-write assuming they are under git control. .git directory must be mounted read-only in order to protect the version control data from unwanted changes and to be able to revert file changes easily.
  • Each container is a fresh scratch container started from a common image.
  • The container network must be a Podman internal network. This way, it cannot access the host or any external network.

If you don’t trust Podman container isolation for the purpose, you can choose other solutions, like virtual machines or combining Podman and virtual machines, e.g. with libkrun (podman run --target=krun). The same principles apply, the implementation will be different.

I use three containers running:

  • ECA, a separate container instance for each ECA session
  • LiteLLM proxy, a common container with a static IP address
  • network proxies, a common container with a static IP address

The containers share a Podman internal network:

podman network create --internal --disable-dns llm

Sandboxing ECA itself

The container image should ideally contain all the system and development tools needed. The agent can be instructed to install whatever it needs itself but this is just a waste of time and tokens.

The container can be run as:

podman run --rm \
       --network=llm \
       --add-host=proxy:PROXY-IP \
       --add-host=litellm:LITELLM-IP \
       -e http_proxy=http://proxy:8080 \
       -e HTTP_PROXY=http://proxy:8080 \
       -e HTTPS_PROXY=http://proxy:8080 \
       -e LITELLM_API_KEY \
       -v $HOME/.emacs.d/eca:/home/pdm/.emacs.d/eca:z,ro \
       -v $HOME/.config/eca:/root/.config/eca:z,ro \
       -v $HOME/.cache/eca:/root/.cache/eca:z \
       $*

where PROXY-IP and LITELLM-IP are the static addresses of the corresponding containers. To get an idea what addresses can be used, run a Podman container with --network=llm, run podman inspect on it, look for Networks entry in the output and llm network to see the network IP address and range.

Further arguments are provided from the Elisp side:

(defun my-eca-wrapper (command roots)
  (append (list "SCRIPT-FILE-STARTING-THE-CONTAINER-LIKE-ABOVE"
                (if roots
                    (file-name-nondirectory (directory-file-name (car roots)))
                  "default")
                "-i")
          (cl-loop for r in roots
                   for dir = (expand-file-name r)
                   for gitdir = (format "%s.git" dir)
                   append (list "-v" (format "%s:%s:z" dir dir))
                   when (file-directory-p gitdir)
                   append (list "-v" (format "%s:%s:z,ro" gitdir gitdir)))
          (list "CONTAINER-IMAGE-NAME")
          command))
(setq eca-process-wrapper-function 'my-eca-wrapper)
(setq eca-send-process-id nil)

This ensures file system isolation:

  • ECA stuff is mounted under /root, read-only (with the exception of the cache).
  • The source directories are mounted read-write, to the same location as on the host to avoid path confusions.
  • .git subdirectories are mounted read-only.

Basic security

The primary danger are ECA tools. If there is a new tool introduced or installed then it may make a new way to access something that shouldn’t be accessible. For this reason, the common default tool access policy should never be allow. Let’s be clear in the ECA configuration file:

"toolCall": {
    "approval": {
        "byDefault": "ask"
    }
},

ECA sets allow permission on some read-only commands by default, but even read-only commands shouldn’t be able to look anywhere uncontrollably.

Execution of shell commands

I use rootless Podman containers. ECA is running under root user there. It’s possible to create an ordinary user in the container, let’s say agent and to run shell commands under this user:

runuser -u agent -- env -u LITELLM_API_KEY bash -c 'properly escaped commands'

This can be set up in the ECA configuration as follows:

"hooks": {
    "sandbox": {
        "type": "preToolCall",
        "visible": false,
        "matcher": "eca__shell_command",
        "actions": [{
            "type": "shell",
            "file": "/root/.config/eca/my-sandox-command.sh"
        }]
    }
},

where my-sandbox-command.sh can be something like

#!/bin/sh

command=$(jq -r .tool_input.command)
wrapped="runuser -u agent -- env -u LITELLM_API_KEY bash -c $(escape $command as best as you can)"
jq -n --arg cmd "$wrapped" '{"updatedInput": {"command": "$cmd"}}'

Then the shell commands don’t have access to files private to ECA and hopefully also not to the ECA process memory (I’m not sure about memory isolation in rootless containers).

There are serious caveats though:

  • If the hook fails or doesn’t work correctly, ECA executes the shell command without being wrapped, i.e. with all the permissions.
  • If the hook succeeds, ECA prints a message informing about modification of on each shell command execution. This cannot be disabled and is somewhat annoying.
  • Escaping the shell commands is tricky; consider commands like ls /home & ls /root. I think I’ve solved it, but I’m not sure enough about my solution to present it here. Do your own research how to escape properly.
  • If the source directory on the host is not accessible to all the users, it won’t be accessible to the shell commands.
  • git may not like the fact that the source directory is owned by somebody else. git safe.directory option should be set for agent user to disable the protection.
  • The worst is that the LLM may grab runuser seen in the previous commands and run it itself, resulting in double runuser invocations and the LLM getting crazy to look for a working command version.

These mean there are no real security guarantees and there are significant inconveniences. Whether running commands under a different users or not, the basic rule is not to put anything sensitive into the container and to be careful about mounting read-write directories into the container. And it’s hard to protect the LiteLLM API key and the ECA cache without the shell command wrapper.

git ECA tool is basically a specialised shell command runner and .git is read-only anyway. It’s best to disable it in the ECA configuration:

"disabledTools": ["eca__git"],

LLM credentials

To protect the LLM credentials, using a LLM proxy is advisable. (On the other hand, adding another component increases risks such as supply chain attacks.) I use LiteLLM, for no particular reason, it’s a free software proxy supported by the tools I use.

The LiteLLM can be run in a separate container, then the actual LLM API credentials are protected from the agent. The agent can steal at most the credentials to the LiteLLM proxy, which are not that useful.

ECA configuration for a LiteLLM proxy can look like this:

"providers": {
    "litellm": {
        "api": "openai-responses",
        "url": "http://litellm:PORT",
        "models": {
            "model-1": {},
            "model-2": {},
            "model-3": {},
            …
        }
    }
},
"defaultModel": "litellm/model-2",

LiteLLM can be run from the LiteLLM image:

podman run -dit --rm --name=litellm \
       --network=llm:ip=LITELLM-IP --network=podman \
       -e LITELLM_MASTER_KEY \
       -e YOUR_LLM_PROVIDER_API_KEY \
       -v …/config.yaml:/config.yaml:z,ro \
       docker.litellm.ai/berriai/litellm:main-latest \
       --port PORT --config /config.yaml

LITELLM_MASTER_KEY contains the password that can be used as LITELLM_API_KEY to access the proxy if no separate LiteLLM API key is set up. The key must start with sk- prefix.

config.yaml can specify just the available models:

model_list:
  - model_name: some-model
    litellm_params:
      model: some-provider/some-model
      api_key: os.environ/YOUR_LLM_PROVIDER_API_KEY
  - model_name: another-model
    litellm_params:
      model: some-provider/another-model
      api_key: os.environ/YOUR_LLM_PROVIDER_API_KEY
  …

Network access

The container should be fully isolated from the host and external networks. At the same time, there must be means to talk to the outside world, to be able to use the model (unless it’s local), MCPs and to access information on the web.

The ECA container uses only the llm internal network and is thus fully isolated. LiteLLM and the proxy containers share this network to be accessible to ECA, and also use podman network to access the outer world.

To connect to the outer world, mitmproxy HTTP proxy can be used. I haven’t explored it much yet, but the basic features are there:

  • Displaying and logging network connections.
  • Interactive approval of selected connections.
  • Redirections.
  • Etc.

The proxy container can be run as follows:

podman run \
       --add-host=host:host-gateway \
       --add-host=litellm:LITELLM-IP \
       --network=llm:ip=PROXY-IP --network=podman \
       -p 127.0.0.1:8081:8081 \
       -v $HOME/.mitmproxy:/root/.mitmproxy:z \

Port 8081 serves as the mimtproxy web interface on the host. mitmweb can be started like this:

mitmweb --set web_host=0.0.0.0 --set web_password=STATIC-PASSWORD-IF-DESIRED

Point your web browser to http://127.0.0.1:8081 to access mitmproxy and configure it according to your requirements. Which network connections to allow and which not is a difficult question, I don’t have much idea about the possible answers yet.

The ECA container is already set up with HTTP proxy environment variables for the controlled internet access via mitmproxy. Note that ECA itself honours the environment variables to some extent but not completely. For example, they may not apply in MCP setup and there is no support for no_proxy, which means the LiteLLM traffic travels through the network proxy.

If anything else than HTTP is needed, ad-hoc socat forwarding should do the job. See below for an example MCP setup. For more complex setups or permissions, consult your firewall.

Displaying diffs

The diffs displayed by LLMs are difficult to read. ECA can display them in Ediff, which is better, but since only the given snippets are displayed rather than whole-file diffs, it’s not very useful. Monet can display and edit full Ediff’s using MCP but it works only with Claude Code.

I think the best way to deal with diffs is to let them apply automatically and view them in Magit (I guess it also saves some tokens). They can be edited there, the acceptable parts staged and the rest discarded or left for the LLM to rework. And the staged changes are safe from further changes applied by the LLM.

MCP

MCP servers are rarely needed, most actions can be run simply as shell commands. But when they are needed then for example EMCP can be used.

One use case is to allow the agents to commit changes. Since .git subdirectories are mounted read-only, it cannot be done in the ECA container. So commits can be made either manually, or (if you manage to persuade the LLM to produce meaningful commit messages) via MCP.

Making EMCP working with ECA is tricky. I couldn’t get the streamable HTTP support in ECA working; it doesn’t honour HTTP proxy environment variables and has apparently other bugs. A workaround is to install mcp-proxy and to set it up as a stdio MCP in ECA configuration:

"mcpServers": {
    "my-mcp": {
        "command": "mcp-proxy",
        "args": ["--transport", "streamablehttp", "http://PROXY-IP:EMCP-PORT/mcp"]
    }
},

where EMCP-PORT is the value of emcp-http-port Emacs variable (assuming it’s the same in the proxy container and on the host), which must be set. Note that the proxy IP is specified, I couldn’t get it running with a host name.

In order to reach the MCP running in Emacs on the host, the following must be arranged:

  • emcp-http-host Emacs variable must be set to a non-local address, e.g. 0.0.0.0 (check your firewall rules to not make your EMCP a publicly accessible instance).
  • A connection forwarder must be run in the proxy container, e.g. socat TCP4-LISTEN:EMCP-PORT,bind=PROXY-IP,reuseaddr,fork TCP4:host:EMCP-PORT.
  • EMCP must be running in Emacs (see emcp-start).

Final remarks

Yes, I know, I should file ECA bugs or even better to post pull requests about the ECA problems mentioned. The problem is that working around software problems is so time consuming these days that one is happy to get it at least running eventually, only to become busy with other software problems. But adding it to my TODO, let’s see.

My setup is no way really secure, but I think it’s OK for now, until agentic LLM attacks become a norm. And it’s definitely much better than the common use of running all the LLM related stuff directly on the host or in a simple container without further thought.

This overview is quick and dirty, I haven’t used all the stuff heavily yet. But I wanted to document it before I forget what I had made exactly.


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *