hardened-terminal-mcp
Use it to safely evaluate and, when allowed, execute policy-controlled terminal commands inside a jail.
run_command: checks a command against deny-by-default allow/deny rules and runs it only if allowed, with no shell, cwd jail containment, timeout, output caps, env scrubbing, secret redaction, and audit logging.explain_command: performs a dry-run policy check and returns the verdict without executing anything.Both return a structured
CommandResultwithok, result code, stdout/stderr, exit code, duration, and policy reason.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@hardened-terminal-mcp list files in the current directory"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
hardened-terminal-mcp
Newer project: safe-mcp-suite combines this terminal server and the file organizer.
A security-hardened MCP server that lets an AI model run a small, explicitly allowlisted set of terminal commands — safely.
Why
Giving a model raw shell access is dangerous: one crafted string can chain commands, read secrets, or escape the working directory. This server turns "let the model run commands" into a bounded, auditable operation instead of an open door. Every command is checked against a deny-by-default policy, run with no shell, jailed to one directory, time- and output-bounded, and logged to an append-only audit trail before it is allowed to run.
Related MCP server: Shell-MCP
Demo
Real output, captured from a jail with ls, cat, echo allowlisted and rm denied:
>>> explain_command("ls -la")
{
"ok": true,
"code": "OK",
"stdout": "",
"stderr": "",
"exit_code": null,
"duration_ms": 0,
"policy_reason": "command 'ls' is allowed"
}
>>> run_command("ls -la")
{
"ok": true,
"code": "OK",
"stdout": "total 44\ndrwxrwxr-x 2 asaad asaad 4096 Jul 30 18:36 .\ndrwxrwxrwt 24 root root 36864 Jul 30 18:36 ..\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 4,
"policy_reason": "command 'ls' is allowed"
}
>>> run_command("rm -rf /")
{
"ok": false,
"code": "POLICY_DENIED",
"stdout": "",
"stderr": "",
"exit_code": null,
"duration_ms": 0,
"policy_reason": "command 'rm' is on the denylist"
}
>>> run_command("cat /etc/passwd", cwd="/etc")
{
"ok": false,
"code": "CWD_ESCAPE",
"stdout": "",
"stderr": "",
"exit_code": null,
"duration_ms": 0,
"policy_reason": "cwd escapes jail root: '/etc'"
}Quickstart
Requires Python 3.12+. Install with uv:
uv syncWrite a minimal policy.toml:
jail_root = "/srv/htmcp/sandbox" # commands are jailed here; REQUIRED
[allowlist]
commands = ["ls", "cat", "echo"]
[denylist]
commands = ["rm"]Register it with your MCP client:
{
"mcpServers": {
"hardened-terminal": {
"command": "uv",
"args": ["run", "hardened-terminal-mcp"],
"env": {
"HTMCP_JAIL_ROOT": "/srv/htmcp/sandbox",
"HTMCP_POLICY_FILE": "/srv/htmcp/policy.toml"
}
}
}
}How it works
flowchart LR
client([MCP client / model]) -->|run_command / explain_command| server[server.py]
server -->|explain| policy[policy engine<br/>deny-by-default, no shell]
policy -->|deny| result[CommandResult envelope]
policy -->|allow| jail{cwd inside jail?}
jail -->|escape| result
jail -->|ok| exec[executor<br/>shell=False, env scrub, timeout, cap]
exec --> redact[redaction<br/>before truncation]
redact --> result
result --> client
server -. attempt record<br/>BEFORE execution .-> audit[(append-only<br/>audit.jsonl)]
server -. outcome record<br/>AFTER execution .-> auditThe audit log is a side branch, not on the return path: the attempt record is
written before the executor runs, and the outcome record after. explain_command
follows the same path with execution skipped.
Security guarantees
Guarantee | How it is enforced |
Deny by default | Command name must be on the allowlist; denylist always wins; a name not listed is denied |
No shell |
|
cwd jail | Requested cwd is resolved (normalising |
Timeout | Wall-clock |
Output caps | stdout/stderr byte-capped with a truncation marker; redaction runs before the cap so a secret can't be split |
Env scrub | Child gets only an allowlisted env ( |
Audit fail-closed | An attempt record is written before execution; if it can't be written under fail-closed, the command does not run ( |
Redaction | Secrets in output and in audit |
Threat model
Does not defend against (out of scope by design):
A dangerous command you allowlisted. If you allow an interpreter or a shell-like tool (
bash,python,sh,find -exec,awk,env, …), the model can do anything that tool can. Policy strength is entirely the operator's allowlist.Kernel / sandbox escapes. The jail is a path-containment check, not a kernel sandbox — there are no namespaces, cgroups, or seccomp. A local-privilege or kernel exploit reachable from an allowlisted binary is not contained.
Host access to the audit log. The trail is tamper-evident to the server, not tamper-proof. Anyone with filesystem access to
audit.jsonlcan read, alter, or delete it.Redaction completeness. Redaction is pattern-based and best-effort; a novel secret format the patterns don't recognise can pass through.
Compared to a naive terminal MCP server
Many quick MCP servers wrap subprocess.run(cmd, shell=True). That is convenient
and unsafe. This table is factual, not a claim of perfect security.
Concern |
| hardened-terminal-mcp |
Command surface | Any command the shell can parse | Only allowlisted names; denylist wins |
Shell metacharacters | Interpreted ( | Rejected before evaluation; no shell |
Working directory | Wherever the process is | Pinned to a required jail root; escapes refused |
Environment | Full parent env (secrets included) | Scrubbed to |
Secrets in output/args | Passed through | Pattern-redacted before return and before audit |
Auditability | None by default | Append-only JSONL; fail-closed by default |
Reference
Both tools return a CommandResult:
CommandResult {
ok: bool # true only for OK / OUTPUT_TRUNCATED
code: ResultCode # see taxonomy below
stdout: str # redacted; empty for explain / non-executing paths
stderr: str # redacted
exit_code: int | null # process exit code; null when nothing ran
duration_ms: int
policy_reason: str | null # human-readable allow/deny reason
}run_command(command: str, cwd: str | None = None)— evaluatecommandagainst policy and, if allowed, run it sandboxed.cwd(if given) must resolve inside the jail root, elseCWD_ESCAPE. Every call is audited.explain_command(command: str)— dry run: return the policy verdict only, with emptystdout/stderrandexit_code = null. It never reaches the executor.
Each code maps to exactly one condition.
Code | Meaning |
| Executed and returned ( |
| Denied by policy (list, arg rule, shell metacharacter, empty) |
| Command could not be parsed into an argv |
| Wall-clock timeout; the process was killed |
| Ran, but output hit the byte cap |
| Requested cwd escaped the jail root |
| Allowed command couldn't run (not found, permission, etc.) |
| Fail-closed: the audit record couldn't be written; not run |
| Unexpected server error; generic message only, no traceback |
Variable | Meaning | Default |
| Path to |
|
| Jail directory (required via this or | — (refuse if unset) |
| Audit trail path |
|
|
|
|
Env values override the file. Startup fails loudly on a missing/invalid policy, an unset or non-directory jail root, an invalid operator regex, or an audit log located inside the jail root.
# jail_root is REQUIRED (here or via HTMCP_JAIL_ROOT). Keep the audit log OUTSIDE it.
jail_root = "/srv/htmcp/sandbox"
audit_log = "/var/log/htmcp/audit.jsonl"
audit_fail_mode = "closed" # "closed" = don't run if unloggable; "open" = run + warn
[allowlist]
commands = ["ls", "cat", "echo", "git"]
[denylist]
commands = ["rm", "shutdown", "curl"] # denylist always wins
# Allowed in general, denied for specific argument patterns (order-independent).
[[rules]]
command = "git"
deny_args = ["push --force", "push -f"]
reason = "force-push rewrites shared history"
[redaction]
enabled = true
entropy_fallback = false # noisy on hashes/UUIDs/base64 — see Caveats
extra_patterns = [] # e.g. [{ name = "internal_id", regex = "INT-[0-9]{8}" }]Honest caveats
This is a hardening layer, not a vault. Read these before deploying.
Redaction is best-effort, not a guarantee. It is regex/pattern-based. It catches common secret shapes (see the pattern list) but a novel or unusual format will pass through. Do not rely on it as your only secret control, and do not allowlist commands that print secrets you can't afford to leak.
The audit log is tamper-evident, not tamper-proof. The server writes append-only with per-record flush+fsync, so it won't lose records to a crash. But anyone with host filesystem access can read, edit, or delete
audit.jsonl. Protect it with OS permissions and ship it off-host if you need integrity.The jail is a path check, not a kernel sandbox. Containment is a resolve +
is_relative_tocheck on the cwd. There are no namespaces, cgroups, or seccomp. A process that can escape via a kernel bug or an allowlisted escape hatch is not contained. For stronger isolation, run the whole server inside a container/VM.Policy strength is entirely the operator's allowlist. The engine faithfully enforces what you configure. Allow
bash,python,env,find, or any tool with-exec/eval semantics and you have handed over a general-purpose shell. Keep the allowlist minimal and argument-scoped.
Testing
uv run pytest84 tests, isolated with tmp_path (they never touch a real policy or audit log):
test_policy.py(22) — deny-by-default, denylist-wins, case/spacing bypasses, metacharacter rejection, argument rulestest_executor.py(7) — shell=False, timeout+kill, output cap, env scrub, cwd validation, refused commandstest_server.py(16) — one test perResultCode, both tools, envelope shape, cwd escapes, explain-never-executestest_audit.py(15) — attempt/outcome records, fail-closed vs fail-open, schema, no output content, redactedcommand_raw/argv, jail-root rulestest_redact.py(23) — one case per pattern, no-false-positives (paths, SHAs, UUIDs,cp -p), entropy on/off, redaction before truncationtest_smoke.py(1) — import/health smoke
CI runs the suite plus gitleaks on every
push and pull request (see .github/workflows/ci.yml).
Related work
Asaad-Suliman/MCP-file-organizer — a companion safe-MCP-server project applying the same deny-by-default, audited, least-privilege approach to filesystem operations.
Asaad-Suliman/safe-mcp-suite — this server's successor: the terminal server and the file organizer rebuilt together on one shared, deny-by-default safety core.
License
MIT — see LICENSE.
Available Tools
2 toolsexplain_commandA
Dry run: return the policy verdict without executing anything.
stdout/stderr are always empty and exit_code is always None. This function does not reference the executor at all, so it is structurally impossible for it to run a command (see test_explain_command_cannot_execute).
Inputs: command (required). Returns: Uniform envelope returned by both tools.
run_commandA
Evaluate command against policy and, if allowed, run it sandboxed.
Denied commands are never executed. cwd (if given) must resolve inside
the jail root; any traversal/symlink/absolute escape returns CWD_ESCAPE.
Any unexpected error becomes INTERNAL_ERROR with a generic message.
Inputs: command (required), cwd. Returns: Uniform envelope returned by both tools.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 2 tools
The two tools have clearly distinct purposes: one executes commands after policy evaluation, the other only simulates to show the policy verdict. Their descriptions explicitly highlight the difference, making it impossible to confuse them.
Both tools follow a consistent verb_noun pattern with snake_case (run_command, explain_command), which is clear and predictable.
With only 2 tools, the surface is very small. While it might suffice for a minimal sandboxed terminal, the server's name suggests broader functionality, so the count feels slightly lacking but not severely mismatched.
The tools cover execution and policy checking, but there is no ability to manage policies (e.g., view, edit, list) or handle command history. This leaves notable gaps that agents would need to work around.
Maintenance
Related MCP Connectors
Security scanner for MCP servers. Detect vulnerabilities, prompt injection, and tool poisoning.
Runtime behavioral trust scoring for MCP servers. Check reliability before calling unknown tools.
An MCP server for deep research or task groups
Related MCP Servers
- AlicenseBqualityCmaintenanceA secure MCP server for Windows Subsystem for Linux environments, facilitating safe command execution with extensive validation and protection against vulnerabilities like shell injection and dangerous commands.773 npm26MIT
- AlicenseBqualityDmaintenanceA secure MCP server for executing whitelisted shell commands with resource and timeout controls, designed for integration with Claude and other MCP-compatible LLMs.20389 npm7MIT
- AlicenseNot gradedqualityDmaintenanceA secure, controlled terminal MCP server that enables executing whitelisted shell commands safely with multiple security layers.MIT
- FlicenseAqualityDmaintenanceA minimal MCP server that lets AI hosts execute shell commands in a sandboxed workspace directory.1-