Skip to main content
Glama
Asaad-Suliman

hardened-terminal-mcp

hardened-terminal-mcp

A security-hardened MCP server that lets an AI model run a small, explicitly allowlisted set of terminal commands — safely.

CI Python 3.12+ License: MIT Tests: 84 passing MCP

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 sync

Write 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 .-> audit

The 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

subprocess.run(argv, shell=False) on a parsed argv; shell metacharacters (; | & < > \ $(...)`) rejected before any policy check

cwd jail

Requested cwd is resolved (normalising .., following symlinks) and must stay inside the jail root; else CWD_ESCAPE

Timeout

Wall-clock timeout= on the subprocess; on expiry the process is killed and TIMEOUT returned

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 (PATH, HOME, LANG); parent secrets are dropped

Audit fail-closed

An attempt record is written before execution; if it can't be written under fail-closed, the command does not run (AUDIT_UNAVAILABLE)

Redaction

Secrets in output and in audit command_raw/argv are replaced with [REDACTED:<kind>]

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.jsonl can 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

subprocess.run(shell=True)

hardened-terminal-mcp

Command surface

Any command the shell can parse

Only allowlisted names; denylist wins

Shell metacharacters

Interpreted (;, |, $(), redirection)

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 PATH/HOME/LANG

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) — evaluate command against policy and, if allowed, run it sandboxed. cwd (if given) must resolve inside the jail root, else CWD_ESCAPE. Every call is audited.

  • explain_command(command: str) — dry run: return the policy verdict only, with empty stdout/stderr and exit_code = null. It never reaches the executor.

Each code maps to exactly one condition.

Code

Meaning

OK

Executed and returned (exit_code carries the process result)

POLICY_DENIED

Denied by policy (list, arg rule, shell metacharacter, empty)

PARSE_ERROR

Command could not be parsed into an argv

TIMEOUT

Wall-clock timeout; the process was killed

OUTPUT_TRUNCATED

Ran, but output hit the byte cap

CWD_ESCAPE

Requested cwd escaped the jail root

EXECUTOR_ERROR

Allowed command couldn't run (not found, permission, etc.)

AUDIT_UNAVAILABLE

Fail-closed: the audit record couldn't be written; not run

INTERNAL_ERROR

Unexpected server error; generic message only, no traceback

Variable

Meaning

Default

HTMCP_POLICY_FILE

Path to policy.toml

policy.toml

HTMCP_JAIL_ROOT

Jail directory (required via this or jail_root)

— (refuse if unset)

HTMCP_AUDIT_LOG

Audit trail path

audit.jsonl (from policy)

HTMCP_AUDIT_FAIL_MODE

closed or open

closed (from policy)

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_to check 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 pytest

84 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 rules

  • test_executor.py (7) — shell=False, timeout+kill, output cap, env scrub, cwd validation, refused commands

  • test_server.py (16) — one test per ResultCode, both tools, envelope shape, cwd escapes, explain-never-executes

  • test_audit.py (15) — attempt/outcome records, fail-closed vs fail-open, schema, no output content, redacted command_raw/argv, jail-root rules

  • test_redact.py (23) — one case per pattern, no-false-positives (paths, SHAs, UUIDs, cp -p), entropy on/off, redaction before truncation

  • test_smoke.py (1) — import/health smoke

CI runs the suite plus gitleaks on every push and pull request (see .github/workflows/ci.yml).

  • 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.

Install Server
A
license - permissive license
A
quality
A
maintenance

Maintenance

Maintainers
Response time
Release cycle
1Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    B
    quality
    B
    maintenance
    A 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.
    7
    102
    20
    MIT
  • A
    license
    B
    quality
    D
    maintenance
    A secure MCP server for executing whitelisted shell commands with resource and timeout controls, designed for integration with Claude and other MCP-compatible LLMs.
    20
    389
    7
    MIT

View all related MCP servers

Related MCP Connectors

  • Security scanner for MCP servers. Detect vulnerabilities, prompt injection, and tool poisoning.

  • An MCP server for deep research or task groups

  • MCP server for Blockscout

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Asaad-Suliman/hardened-terminal-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server