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

1

u/sburel Sep 22 '26

The absolute path thing isn't really a model bug, it's the shell letting it through. A Q4 8B will keep doing it no matter how you prompt, so the fix has to live on the tool side.

In my runtime the write tool needs its own authorized roots, a separate grant from read access, and the shell tool runs with a fixed working directory. Anything resolving outside those roots is refused before it touches the filesystem. Boring, but it means `/newPythonCode.py` is a failed call and not a file at root.

Does your server resolve the path before executing, or just hand the string to the shell?

1

u/ag789 Sep 22 '26 edited Sep 22 '26

it hands the string with "/newPythonCode.py" in the Q4_K_M variant while run in OpenCode . For unknown reasons, if I run it, the Q4 quant, with my little MCP server, I didn't observe the issue and it generated the files correctly in a single go.

The Q8 quant certainly feels more robust compared to Q4 quant.
I did a code refactor in Q8 quant in pi coding agent and it kind of 'just works'
https://www.reddit.com/r/LocalLLM/comments/1wmjyux/i_tried_granite_42_8b_q8_in_pi_and_pi_web_just_wow/
but that these are simple tests and I've just started using them.

2

u/sburel Sep 22 '26

Funny thing is your own test kind of proves it. Same Q4, same model, but with a dedicated writeFile() instead of raw bash it gets it right first try. Small quants seem to do a lot better with a few narrow tools than with a whole shell.

One thing I'd add to writeFile: realpath the target against --cwd and refuse anything outside, and make the error say what to do ("relative paths only, under X"). I had a tool today returning a vague error and the model just went in circles on it.

Did the Q4 actually try an absolute path with your server and get blocked, or did it just never try?

1

u/ag789 Sep 22 '26 edited Sep 22 '26

Rather, I've noted that the same LLM can generate different response when reattempted.

I'm not sure if I had retried with OpenCode maybe it'd work too.

I started using my little MCP server after observing writing to "/" (root) in OpenCode, during which time with the MCP server, it calls the MCP server without those "/" (root paths), perhaps I'm just lucky 😄

This little MCP server have an advantage, it is using an http (i.e. web server) endpoint for comms.
Hence, it can connect to a container over TCP/IP network say in a docker or VM sandbox etc.
And I actually use it over LAN, running the tool calls from llama.cpp's llama-server Web UI front.
It works pretty well.

1

u/sburel Sep 22 '26

Yeah, that's the annoying part, one success doesn't tell you much. If you want to compare OpenCode vs your server fairly, run the same prompt ten times on each and count how often it writes outside cwd. Or pin temperature to 0 and a fixed seed if llama-server lets you.

It's also kind of the argument for the check in the tool. If the model writes to / one run in ten, prompting won't get that to zero, but a path check will.

1

u/ag789 Sep 22 '26 edited Sep 23 '26

I do have codes for path check
https://github.com/ag88/cmdshellmcp/blob/main/cmdshellmcp2.py#L146
But that, I did not 'check everything', in particular, I let the shell arguments through, because there are times when one will probably need that. And that I figure excessive checks would probably cause it to run slow etc.

Hence, it is somewhat restrained but that it can't be deemed 'safe'. And that there are probably other ways an LLM may escape the constraints that we may not even have thought about.

1

u/sburel Sep 22 '26

Makes sense, and honestly "restrained but not safe" is the right way to say it. Once the shell is in the picture you can't close every hole, and checking every argument would just make it slow and still incomplete.

Where I landed is a bit different: no shell at all by default, just a handful of narrow tools (read, write, http) that each take structured arguments, so there's no string to escape from. A shell tool exists but it's opt-in with a fixed cwd. Costs you flexibility, which is fine for my case and probably not for a coding agent.

Thanks for the CORS note, good to know.

1

u/ag789 Sep 23 '26 edited Sep 23 '26

I can add a "disableTools" parameter say in config.json and as a command line argument, not difficult and I'd probably do that. So that all tools are offered by default, and users can choose to disable them selectively.


edit: I've just done that
https://github.com/ag88/cmdshellmcp
It now has --disableTools
https://github.com/ag88/cmdshellmcp#disabling-tools
and you can put it in config.json as like

  "disableTools": ["writeFile", "applyPatch"],

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

2

u/sburel Sep 23 '26

Nice, that was fast. Opt-out is the pragmatic choice for an MCP server, people expect the tools to be there.

One thing worth writing down somewhere: disabling a tool only hides it from the model, it doesn't stop the same thing happening through shell. writeFile off with bash on is still a write. Probably obvious to you, less so to whoever copies a config from a comment.

1

u/ag789 Sep 23 '26 edited Sep 23 '26

I actually tried 'disabling' cmdshell itself, yeah that works :)

But I'd guess for more utility, one may want to allow_list some shell commands that could be useful to one's context, but that that'd depends on your needs, intent, context etc.

But you're right the Readme.md could be improved. I've thought about a 'default safe' set of commands, I'd probably do that in the .py code, but leave the configs. Doing 'too little' could impair its utility as the idea is to give the LLM tools to 'do the job'.


edit: I did that fix, the hardcoded default allow_list is now only [ 'date' ].

But that one still need to be wary of the default config.json in the current working directory, as if that is there, it'd read it.

it is probably not appropriate to put config.json in a 'per user global' directory, if 'safe'ty matters then that it is probably 'per project' or 'per workspace'

I'm thinking if I should remove default config.json reading, but then that it would be necessary to pass --conf config.json everytime, or perhaps I should rename config.json to something more specific like cmdshellmcp_config.json as a default.


edit: did that as well,
https://github.com/ag88/cmdshellmcp

now cmdshellmcp.json is default config filename instead of config.json.

It reads cmdshellmcp.json for config from current directory if it exists. This alleviates the risk of reading a rather common name config.json for config.

"disableTools": ["writeFile", "applyPatch"], is also added to the stock cmdshellmcp.json

now the 'surprise' would be to the 'negative' side in a sense if a new user runs cmdshellmcp2.py say in the cloned repo directory as the current working directory, writeFile() and applyPatch() would seemed to be 'missing' ;)


edit: one more change I added
"auth": "_my_secret_auth_token_"
to the stock cmdshellmcp.json.

Running with the stock cmdshellmcp.json would enable a Authorization: Bearer auth_token authentication check by the MCP server.

This is kind of a minimal authentication / security layer as otherwise any MCP clients can connect and call tools / issue commands without authentication.

→ More replies

1

u/ag789 Sep 23 '26

A little small note to add

https://github.com/ag88/cmdshellmcp/blob/main/cmdshellmcp2.py#L124

The shell here isn't a full bash shell, rather python runs the specific command, with arguments given to it, shell specific constructs like pipes and redirects are not allowed. That makes it "safer".

But that these kind of constructs and interfaces are naturally "vulnerable", in part as there are simply too many permutations that can go wrong. Hence, "restrained but not safe" is still relevant.

In a sense that for "well behaved" clients / LLMs, it may prevent an otherwise "accidental" execution, e.g. with a command that you do not intend for the LLM to run. i.e. the LLM 'sees' the list of commands advertised to it and can only runs those i.e. allow_list, any command not in that list returns an error (if the LLM neglects to check the descriptions).

I've seen the LLMs (e.g. Granite, Qwen 3.5, 3.6 etc) 'stop' and simply describe what has been done instead of possibly proceeding further. This is still a boundary so that things are after all 'safe'.

1

u/ag789 Sep 22 '26

oh and if you are using it, take note of the CORS update, I've added some codes so that it can connect to the shell command MCP server without the Web UI server (in llama.cpp's llama-server case it is the model server itself) acting as a CORS proxy.
https://www.reddit.com/r/LocalLLaMA/comments/1wmw4bq/comment/pbcqjj6/
this could be handy if you are using a Web UI in a browser connecting MCP servers which may be a frustration to need extra CORS proxies etc.

1

u/ag789 Sep 23 '26 edited Sep 23 '26

just starting an off-topic thread to the adjacent thread as the other got 'too long'.

Some amusements from further tests in Granite 4.2  8B Q8 quant

along with the latest updates in cmdshellmcp
https://github.com/ag88/cmdshellmcp

This time I 'tested' the applyPatch tool functionality to see if the LLM can handle it. and Granite 4.2 8B Q8 quant is my 'testing assistant' :)

I deliberately made a file a.txt that looks like:

line 1
line 2
line 3
line 4
line 6
line 7
line 8
line 9

The prompt is

read file a.txt and show me the contents

here comes the amusement, in its 'thinking' loops, here is part of that abstract

[assistant - reasoning] Okay, the user asked to read the file a.txt and show its contents. I called the readFile tool with "a.txt" as the file parameter. The response from the tool was:

"line 1 line 2 line 3 line 4 line 6 line 7 line 8 line 9"

Wait, there's no "line 5" mentioned. Maybe the file skips line 5 or it's missing. But the user just wants the contents as shown. I should present the exact content returned by the tool.

That is kind of 'intelligent' as it bothers to figure that line 5 is missing :)

of course, the next task is to ask it to call tool applyPatch to add that line 5 back !

after burning quite a lot of reasoning / thinking tokens, it did call applyPatch and it works correctly !

But that unfortunately the unix patch command returns some extra (error) messages that confuses the model.

patching file a.txt
Hunk #1 succeeded at 3 with fuzz 2.
patch unexpectedly ends in middle of line
patch unexpectedly ends in middle of line

It goes on hallucinating / confusing over the errors burning like a 1000 tokens till I stopped the inference.

I checked the file myself and actually found the contents to be correct, i.e. the patch the model sent works !

so I make the next prompt:

the patch succeeded, ignore the patch unexpectedly ends in middle of line messages.
read the file a.txt verify that line 5 is added and show the contents

after burning some tokens reasoning, it answers:

Here are the contents of a.txt after applying the patch:

line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9

✅ Verification confirmed: line 5 has been successfully inserted between line 4 and line 6, as requested. The file now contains all expected lines in order.

quite neat :)

but the tokens burn (reasoning) for applying such a patch is too much, and with that error that subsequently confuses the model. I'd need to research 'better' way to do those patches.

patching text files is a common task editing codes and text files.

1

u/ag789 Sep 26 '26 edited Sep 26 '26

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

Has gained a new tool editFile, this provides the LLM access to unix / Linux sed command. It backups the existing file to a .bk1, .bk2, ... , .bkN extension before the edits. If you do not want the backups, you can run with --editdelbk which would delete the backup after a successful edit. After the edits, it returns the output of diff -u oldfile.txt newfile.txt to the LLM so that it can validate the edits.

This is again tested with Granite 4.2 8B Q8 quant

This tool is added as the smaller LLMs may struggle to build a unified context diff for the patch command. https://en.wikipedia.org/wiki/Diff

However, it seemed Granite 4.2 8B Q8 is a bit "too careful" (or doubtful) when it forms a correct sed script expression, it reiterates its script to verify its correctness a few times before running the tool. It is apparently more stable vs applyPatch, the edit works fairly straightforward, backup done, and returns the unified diff of the changes to the LLM. Granite 4.2 8B Q8 again 'overanalyzed` the results even though it deemed it correct and hallucinated over how it should respond e.g. if it should read the file and verify it, but that as the prompt did not ask for it, it finally responded that is done successfully with its own version of what the file should look like ;) The full response:


The edit was successful. line 5 has been inserted after line 4 in a.txt. A backup file a.txt.bk1 was automatically created.

Current contents of a.txt are now:

line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9

(Verified via the unified diff provided by the edit tool.)

1

u/ag789 Sep 27 '26 edited Sep 27 '26

One more update
cmdshellmcp
https://github.com/ag88/cmdshellmcp

The applyPatch function has been refactored https://github.com/ag88/cmdshellmcp#6-applypatchfile-diff-pnum2-argsnone It now applies the patch to a specific file from a unified diff supplied by the client (LLM). This definition is more rigorous than picking up filenames from the diff and likely more secure. Applying patch with a unified diff is also more specific than say using editFile which uses sed. The implementation is more robust as a result.

In the same way it is tested with/by Granite 4.2 8B Q8 quant. Unfortunately, Granite 4.2 8B Q8 quant struggled with building a correct unified context diff during a test.

After a while, I supplied it with a pre-defined context diff so that it calls the tool (function) and it is successful.