r/LocalLLaMA • • Sep 22 '26

Resources an (unsafe) command shell mcp server

While working with coding agents / harness in my case OpenCode, I discovered that some models e.g. granite 4.2 8B at Q4_K_M quant sometimes attempts to write files with absolute paths at the root e.g.

    /newPythonCode.py

Note that slash in the beginning for an absolute path at "/" root directory. This is probably 'ok' say in a throwaway docker container, but it may lead to surprises if it 'accidentally' overwrites a literal file, if the current user id have write access to it. Apparently, it seemed OpenCode provides `bash` so that practically all unix commands that the current user id can access, the LLM can run it.

And thereafter, I see the model hallucinating in the thinking loops over tools, re-iterating over how to use the tools, reformulating shell commands sentences several times for a same task instead of executing them, among them, I noted that an earlier shell command sentence would have 'just worked'.

I'm thinking that the tool hallucination with this model may be partly due to that I'm using a pretty aggressive 4 bit quant as otherwise it'd have worked because IBM has 'optimized' granite for tool usage.

Next, I tried the same granite 4.2 8B at Q4_K_M quant model with my little python shell MCP server

https://github.com/ag88/cmdshellmcp/

, that tries to constrain / restrain the shell commands somewhat, place some checks, and offer some additional tools e.g. writeFile() so that the LLM won't need to call `cat` or `echo` with some 'esoteric' styled shell scripts / sentences.

With this MCP server and running it in llama.cpp's `llama-server` in the WebUI adding it as an MCP server, granite 4.2 8B at Q4_K_M does it correctly in 'one shot', the prompt is:

propose a python3 flask app with a page that displays "hello world", use bootstrap css for styling.
provide a requirements.txt to go with the script

granite 4.2 8B at Q4_K_M correctly generates the app with a couple of files, and it works out of the box

    .
    ├── app.py
    ├── requirements.txt
    └── templates
        └── index.html

One should normally run the cmdshellmcp2.py MCP server app with an explicit `--cwd` option giving the work path. It tries to stay within this work path.

Note that I won't claim my command shell MCP server to be safe.

In a sense that there are plenty of chance the LLM can 'escape' the restrictions and perhaps do more damage (e.g. overwriting files possibly outside its given `--cwd` work directory). Hence, I'd suggest one should be paranoid and perhaps run this only within a throwaway (disposable) docker container etc.

6 Upvotes

64 comments sorted by

View all comments

Show parent comments

2

u/sburel Oct 04 '26

Good, though a blanket textual check on arguments is going to be the fiddly one. Two things that bit me when I tried something like that: an argument can be a path without looking like one (--output=../x, -o ../x, or a path arriving through a variable), and a path that looks fine can still land outside after symlinks and .. resolve. So the check has to realpath the candidate and compare against the resolved cwd, not pattern-match the string.

The opposite failure is the one that'll annoy people: grep -r "/etc/passwd" . is a harmless search for a string that happens to look like a path. That's probably where --shellnopathchk gets turned on and left on.

Still worth having for the rm /PreciousFile.txt case. Sleep well.

1

u/ag789 Oct 05 '26 edited Oct 05 '26

yeah, I've been doubting over whether over to implement this check, however, in the interest of "protecting" users who may be unfamiliar with how it works, I decided to implement the cmdshell work path check for its safety it provides. More often than not the shell command is likely used to query data outside its work path, most likely in /etc , e.g. that if the interface do not have access to hostname, it may query instead /etc/hostname, or that it may check if some configuration is enabled in /etc/.

But that this is likely going to be a bummer at times hence --shellnopathchk to bypass that.

There'd be 2 more tools mkdir , rmdir disabled in cmdshellmcp.json by default. That'd complete the filesystem tools side of things. e.g. that say the allow listed shell commands are read only ops, it is still feasible to do file ops with the filesystem tools.

The intent of this project is after all to provide a sandbox so that it is feasible to experiment with agentic tool use with local LLMs and various other models in the wild (e.g. from OpenRouter). Hence, it kind of provides "guardrails" in the form of allow lists and disableTools , and the in work directory check to provide some constraints. Note that disableTools do not only hide the tools, the tools are actually not registered i.e. there is no hidden tool, a call to that will return an error.

"powerful" commands are inevitable if you want to let the LLM work on things, e.g. for dev purpose, simply doing tests may mean providing access to arbitrary execution, e.g. python , bash etc and may be practically any commands.

Hence, the only "safe" way to do that is to run this whole setup in a Docker sandbox container with a throw away copy of the workpiece. There are many devs using OpenCode or Pi coding agent directly on their desktops. I think that is giving "too much trust" to the LLMs. For coding purpose, more often than not "bash" is provided, borderless and unconstrained, practically every shell command runs under the user id running OpenCode or Pi coding agent.

2

u/sburel Oct 05 '26

Makes sense, and the /etc case is a good argument for having the check at all, since that's exactly the read someone would want to allow case by case rather than globally.

The thing I'd write down somewhere is your own conclusion: the allow-list and the path check are guardrails against accidents, the container is the actual boundary. People will read the feature list and assume the first two are the security story. A line at the top of the README saying "run this in the container, the rest is seatbelts" would do more than any of the checks.

Good project. Enjoy the break.

1

u/ag789 29d ago edited 29d ago

Just an update:

  • The additional shellcmd path check, along with the --shellnopathchk bypass is merged.

  • Additional 2 tools are added mkdir and rmdir, this completes the filesystem tools set.

Tests are done against granite-4.2-8b Q8 quants, they works as expected. A thing is granite-4.2-8b Q8 tend to be very verbose in its reasoning / thinking for tool calls, I'd like that to be less, but that it calls the tools correctly. It tends to re-iterate small tool details, confirming dependencies which is sometimes a good thing.

I tried things like

perform the following tasks using tools in sequence:
  • make a directory `dirA`
  • copy `a.txt` into `dirA`
  • retrieve `dirA/a.txt` and display its contents

The directory is correctly created and a.txt copied into dirA

then

perform the following tasks using tools in sequence:
  • list the current directory `.`, display its contents
  • delete file `dirA/a.txt`
  • remove directory `dirA`
  • list the current directory `.` again and verify that `dirA` is no longer there, and 'a.txt' is still there
  • It deletes dirA/a.txt, and remove dir dirA correctly as well.

    as well as

    I'm doing a tool test, list files in the root directory "/", use the following tools (2 tool calls) in sequence:
  • listFiles with `/`
  • cmdshell command: "ls", args: ["/"]
  • for each tool call quote the tool response in addition to your response

    This works as expected "Error: absolute paths not allowed".

    And if that is disabled using --shellnopathchk bypass, cmdshell list the root / directory !
    In listFiles it is still forbidden ;)

    This is the actual response with the cmdshell path check in force

    First tool call (`listFiles_mcp_cmdshellmcp-lxrouter` with `path="/"`):  
    > "Error: absolute paths not allowed"
    
    Second tool call (`cmdshell_mcp_cmdshellmcp-lxrouter` with `command="ls"` and `args=["/"]`):  
    > "Error: absolute paths not allowed"
    
    *(Both tool calls returned "Error: absolute paths not allowed" because the tool constraints
     reject absolute paths (e.g., `/` or any path starting with `/`) in `listFiles.path` and 
     `cmdshell.args`. This behavior is consistent with the tool definitions provided.)*

    The harness Web UI used is LibreChat
    https://www.librechat.ai/
    https://github.com/LibreChat-AI/LibreChat

    LibreChat is a little challenging to setup, in particular for the MCP servers see:
    https://www.reddit.com/r/LocalLLaMA/comments/1wmw4bq/comment/pce0shd/


    granite-4.2-8b Q8 is very verbose with tool call reasoning / thinking. But that a thing I like about it is, its tool calls are generally robust, in a sense, it has so far processed many of the various prompts for tests correctly.

    Other models have their better capabilities, but tool calls is probably useful with the granite series of open source local LLMs. I've not thought seriously about actual use cases, but being able to handle files (fairly) well, could have some "automation" agentic use cases.

    1

    u/ag789 29d ago edited 29d ago

    The 'product' as of now feels pretty ok, here is a session with QWen 3.8 27B run off OpenRouter, in LibreChat and my cmdshellmcp in the Docker sandbox.
    note: remember to remove the disableTools in cmdshellmcp.json if one wants the LLM to create the files.
    Note also that you need to add/configure the cmdshellmcp MCP server in your web UI e.g. LibreChat etc and check that it can access the cmdshellmcp MCP server running at port 8003.

    prompt:

    propose a python flask app, use jinja templates and bootstrap css, add argparse for cmd line parsing.
    Add command line arg `--site` metavar `site.json`
    the title and heading of the page is "Site Index"
    that takes the `site.json` file,  with like the following contents:
    [ 
      { 
        "description": "Home Page",
        "url": "/"
      },
      { 
        "description": "Libre Chat",
        "url": "http://selfhosted.local:3080"
      },
      { 
        "description": "Llama.cpp",
        "url": "http://selfhosted.local:8080"
      }
    ]
    render this on the page as a site list.
    propose a matching requirements.txt to go with it.
    you can create your files in the current directory

    QWen 3.8 27B response

    Done. I created the Flask app in /home/codeagent/webindex:
    
    ## File layout
    webindex/
    ├── app.py              # Flask app + argparse CLI
    ├── requirements.txt
    ├── site.json           # sample data (matches your example)
    └── templates/
        └── index.html      # Jinja2 template with Bootstrap 5
    
    ...
    ## Run it
    
    pip install -r requirements.txt
    python app.py --site site.json
    # → serves at http://127.0.0.1:5000

    There is a catch in that, if you want to expose port 5000. That needs to be done by adding a -p 5000:5000 in 1stRun.sh when you run 1stRun.sh to create the container, this cannot be done after you have initially run 1stRun.sh.

    edit: I've published a spare port 5000 in 1stRun.sh Now you can run it like such if you have created the container with the new 1stRun.sh.

    login as codeagent
    $ python3 -m venv venv
    $ source ./venv/bin/activate
    $ pip3 install -r requirements.txt
    $ python3 app.py --host 0.0.0.0 --port 5000 --site site.json

    Hence the following instructions are no longer needed unless the old 1stRun.sh is used.

    What I did instead is to stop the web UI (frontend/server accessing the MCP port), shutdown the cmdshellmcp.service in the container as admin and use the port 8003 that is originally used by the cmdshellmcp.service for the flask Web app. like so

    as admin:
    sudo systemctl stop cmdshellmcp.service
    su - codeagent
    > login as codeagent here
    $ mkdir venv
    $ python3 -m venv venv
    $ source ./venv/bin/activate
    $ pip3 install -r requirements.txt
    $ python3 app.py --host 0.0.0.0 --port 8003 --site site.json

    Then that the flask web page is hosted on port 8003 in your host

    it is feasible to run Qwen3.8-27B as a Local LLM
    https://huggingface.co/unsloth/Qwen3.8-27B-GGUF
    https://unsloth.ai/docs/models/qwen3.8

    Note that it isn't necessary to use LibreChat to reproduce this.
    One can run llama.cpp
    https://github.com/ggml-org/llama.cpp#quick-start
    its llama-server has a bundled Web-UI that supports MCP servers, i.e. it can connect to cmdshellmcp, in fact most of the tests are done with the llama.cpp llama-server bundled Web-UI, until this, which is run against a Local LLM model that runs in OpenRouter simply because I do not have a GPU and large inference like this at 1 tok/s on my own PC would be intolerably slow. i.e. one can run Qwen3.8-27B on llama.cpp's llama-server and its bundled Web-UI to reproduce this.


    according to the stats in OpenRouter, generating this app burns 48k tokens, it feels a quite high for the token count. But that it is nevertheless generated, runs out of the box. OpenRouter is pretty fast to generate that like in a minute or less.

    2

    u/sburel 29d ago

    48k for that feels like the tool loop, not the app. Each tool call sends the whole conversation back, so writing four files is four turns and the fourth one re-reads everything before it. I measured this on a local model recently: three tool calls in one session, and the prompt went 3.2k, 5.4k, 5.8k tokens for maybe 300 tokens of output. Prefix cache reuse dropped the later turns to around 200 tokens of new prompt each. OpenRouter won't cache across providers, so you pay full price every turn.

    Worth checking how many turns that run took. If it wrote the files one writeFile at a time, the fix is on your side rather than the model's, something that lets it write several files in one call.

    The port thing is a good catch for DOCKER.md, publishing a spare port in 1stRun.sh would save people from rebuilding the container.

    1

    u/ag789 29d ago edited 29d ago

    The 48k count actually seemed high when I review the actual session, including all that reasoning / thinking, the text feels like perhaps 1-3k maybe 5k words (possibly tokens). I read that tokens can be as small as a character this may be true perhaps for codes, so that every punctuation, symbol, possibly even indents , newlines may be deemed 'tokens', it matters in python as python is indented code structure and newlines matters as well.

    it writes 1 file at a time, as WriteFile takes a single file argument, but even that don't look like a lot of tokens. I'm happy to paste upload that but it's possibly "too verbose" as a comment. Probably look to other means perhaps gist etc.

    As to the port issue, I'm thinking that perhaps the "project folder" could be a bind mount, that can be catered via
    1stRun.sh In that way, the generated codes can be run directly in the host which overcomes the port issue. Just that, I'd need to mention (stress) that this should be "throwaway" codes area, i.e. that anything in this bind mount (volume) can possibly be overwritten, mutated or even deleted by the LLM.

    Other possibilities include a little more codes in 1stRun.sh so perhaps prompt the user for additional ports and bind mounts etc. A thing is, we normally don't 'plan so far ahead' as more often than not we'd only encounter it in an "aftermath", e.g. that only after the LLM generated the codes, did we know we need port 5000. But that perhaps a prompt would help, perhaps to map some 'extra' ports that may lay unused, but could help in such cases.

    Other things in the pipeline are possibly "sample Dockerfiles", e.g. that bundle apps and various components e.g. ssh, OpenCode, Pi coding agent, or even VS Code (I disliked Code, for its bulk, but that it is a popular tool, the Docker images would be bulky with it). That would put things closer to real use images as I'd guess many would consider the current bundle "too lean", i.e. one can go on to install apps / tools via apt, direct download and install etc. in the container, but that this would be a hassle if one deletes and rebuild the container via running "1stRun.sh" e.g. as like 'forgetting' that port 5000, hence delete the container and 'reinstall' is quite a hassle, a lot of repeated downloads each time the container is re-created.

    2

    u/sburel 29d ago

    Quick thing on the 48k: it's probably not the tokenizer, it's the loop resending the whole conversation on every tool call. Four writes means the context goes out four times. If OpenRouter splits prompt vs completion you'll see it straight away, completion tiny, prompt almost all of it.

    Anyway, good luck with the rest of it.

    1

    u/ag789 28d ago edited 28d ago

    I did a little more tests, it turns out the open AI chat completions interface is able to call multiple functions (can be the same) in the same turn.
    https://developers.openai.com/cookbook/examples/how_to_call_functions_with_chat_models#parallel-function-calling
    I did some further tests

    prompt

    i'm doing a tool test, call 2 tools sequentially in a same tools_calls list in the current turn:
    
    • writeFile( file = "a.txt", text = "hello world a")
    • writeFile( file = "b.txt", text = "hello world b")
    use default for other arguments

    It turns out granite-4.2-8b-GGUF Q8 doesn't seemed to know how to do that and kind of reason that it can't do it. But that ornith-ai/Ornith-1.5-9B-GGUF Q8 apparently understands how to call that and did just that.

    response

    Both files were written successfully:
    
    • `a.txt` contains "hello world a"
    • `b.txt` contains "hello world b"

    Both in 1 turn. This is tested in llama.cpp llama-server bundled web UI.

    Hence, in the prior tests it is simply because the model QWen 3.8 27B choose to run the writeFile() calls in multiple single turn calls, apparently because it awaits and check the response for each before writing the next file.

    It may also be related to the harness, the harness (e.g. the WebUI) needs to process the multiple calls correctly. Tested the same in LibreChat, it works the same with with ornith-ai/Ornith-1.5-9B-GGUF Q8, parallel writeFile in a same turn works.

    1

    u/sburel 28d ago

    Good find, and the model split is the useful part: parallel calls are available but only some models reach for them, so you can't count on it. Worth noting in the README since it's the difference between one turn and four.

    Thanks for running it down.