An MCP server that runs allowlisted shell commands. Designed to fail closed: unknown commands are rejected, arguments are validated against per-command rules, and execution never goes through a shell.
Every call is filtered through layers that must all pass:
- Command allowlist — only commands listed in
commandsare runnable. Shells and interpreters (sh,bash,env,sudo,python,node,perl, …) are hard-denied even if listed. - No shell interpretation — commands are executed via
spawn(cmd, argv, { shell: false }).ls; rm -rf ~is a single argument, not a pipeline. - Argument validation — per-command
argPattern(regex),subcommandsallowlist,maxArgs, and a default denylist of shell metacharacters (;|&`$<>()newlines, tabs, backslash) and NUL bytes.-c,--exec,--eval,-eare always rejected. - cwd confinement — execution is pinned to the configured
cwd. Optional per-callcwdoverride must resolve under that root. - Minimal env — the parent process env is not inherited. Only keys from the config's
envare passed. - Timeouts & output caps — SIGTERM on timeout, SIGKILL 2s later. stdout/stderr truncated to
maxOutputBytes. - Audit log — every accepted and rejected call is appended to a JSONL file when
auditLogPathis set.
Allowlisting certain commands effectively defeats the allowlist. Avoid these unless you know what you're doing:
find(via-exec),xargsmake,npm,yarn,pnpm,pip— they run scripts from files under cwdgitwith unrestricted subcommands —git config core.sshCommand=…thengit fetchexecutes arbitrary code; restrict viasubcommands- Any editor or pager that can shell out (
vim,lesswith!)
The example config (examples/readonly-git.config.json) restricts git to read-only subcommands.
npm install
npm run buildCreate a JSON config. The schema at schema/config.schema.json provides editor validation and hover docs — reference it via $schema or a .vscode/settings.json mapping.
{
"$schema": "./schema/config.schema.json",
"cwd": "/absolute/path/to/workdir",
"timeoutMs": 30000,
"maxOutputBytes": 1048576,
"env": { "PATH": "/usr/bin:/bin", "HOME": "/tmp" },
"auditLogPath": "/tmp/bash-mcp/audit.log",
"commands": {
"git": {
"subcommands": ["status", "diff", "log", "show", "rev-parse"],
"argPattern": "^[A-Za-z0-9._/@:=-]+$",
"maxArgs": 10
},
"ls": { "argPattern": "^[A-Za-z0-9._/-]+$", "maxArgs": 5 },
"cat": { "argPattern": "^[A-Za-z0-9._/-]+$", "maxArgs": 3 },
"rg": { "anyArgs": true, "maxArgs": 10 }
}
}| Field | Type | Notes |
|---|---|---|
cwd |
string | Absolute path. All execution confined here. |
timeoutMs |
integer | Default 30000. Max 600000. |
maxOutputBytes |
integer | Per-stream cap. Default 1 MiB, max 16 MiB. |
env |
{string: string} |
Passed verbatim to children. Parent env is dropped. |
auditLogPath |
string | Optional. JSONL audit log path. |
commands.<name> |
object | See below. |
Per-command rule:
| Field | Type | Notes |
|---|---|---|
subcommands |
string[] |
If set, args[0] must match one of these exactly. |
argPattern |
regex string | Every arg must match unless anyArgs: true. |
anyArgs |
boolean | Bypass argPattern (denyChars still applies). Use sparingly. |
maxArgs |
integer | Hard cap on args.length. Counts the subcommand too — tailscale up is 1, so set maxArgs: 1 (or omit). |
denyChars |
string | Override the default metacharacter denylist. |
node dist/index.js --config /abs/path/to/config.json~/.claude/settings.json:
{
"mcpServers": {
"bash": {
"command": "node",
"args": ["/abs/path/bash-mcp/dist/index.js", "--config", "/abs/path/config.json"]
}
}
}run_command—{ command, args?, cwd? }→ stdout/stderr/exit code.cwdmust be under the configured root.list_allowed_commands— returns a human-readable dump of the allowlist and each rule.
JSONL, one record per call:
{"ts":"2026-04-20T…","command":"git","args":["status"],"cwd":"/…","accepted":true,"exitCode":0,"durationMs":42,"stdoutPreview":"…"}
{"ts":"2026-04-20T…","command":"rm","args":["-rf","/"],"cwd":"/…","accepted":false,"rejectReason":"command \"rm\" is not in the allowlist"}Rejected calls are logged too — review periodically to spot probing.
npm test # validator unit tests
npx tsc --noEmit # type check
npm run dev -- --config examples/readonly-git.config.jsonValidation logic lives in src/validator.ts and is pure — add a failing test there before changing behavior.