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.

Available Tools

2 tools
explain_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).

ParametersJSON Schema
NameRequiredDescriptionDefault
commandYes

Output Schema

ParametersJSON Schema
NameRequiredDescription
okYes
codeYes
stderrNo
stdoutNo
exit_codeNo
duration_msNo
policy_reasonNo

TDQS

A4.4/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description fully bears the burden and explicitly states that stdout/stderr are empty, exit_code is None, and it is structurally impossible to run a command, citing a test for evidence.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is three sentences, front-loaded with the key purpose, and each sentence adds essential information without redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the output schema exists and the tool has one parameter, the description covers behavior, safety guarantees, and differentiation from the sibling, providing complete context for an agent.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The input schema coverage is 0%, and the description does not add any meaning to the 'command' parameter beyond its type. It does not explain its format, constraints, or relationship to the tool's purpose.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states it is a dry run that returns the policy verdict without executing anything, and it distinguishes itself from the likely sibling tool 'run_command' by emphasizing that no execution occurs.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage for checking policy without execution, and the sibling 'run_command' provides a clear alternative. However, it does not explicitly state when to use it or when not to, relying on the contrast with the sibling.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

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.

ParametersJSON Schema
NameRequiredDescriptionDefault
cwdNo
commandYes

Output Schema

ParametersJSON Schema
NameRequiredDescription
okYes
codeYes
stderrNo
stdoutNo
exit_codeNo
duration_msNo
policy_reasonNo

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description carries full burden. It discloses policy evaluation, sandboxed execution, denied commands not run, cwd must resolve inside jail root or return CWD_ESCAPE, and unexpected errors become INTERNAL_ERROR. This is thorough behavioral disclosure.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two sentences long, front-loading the main action and constraints. Every sentence adds value without redundancy. It is efficient and well-structured.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity (command execution, two parameters, no annotations, but with output schema as per context), the description covers policy, sandboxing, cwd constraints, and error scenarios. The output schema exists, so return value explanation is not required. Completeness is sufficient.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must add meaning. It explains that 'command' is evaluated and run after policy check, and 'cwd' must resolve inside jail root with specific escape handling. This adds significant context beyond the raw schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool evaluates command against policy and runs it sandboxed if allowed. It uses specific verbs ('evaluate', 'run') and identifies the resource ('command'). The sibling tool 'explain_command' provides contrast, making the purpose distinct.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage context: use when you want to execute a command sandboxed after policy check. It details denied commands are never executed and cwd constraints. However, it does not explicitly state when not to use this tool or directly name alternatives beyond the sibling name.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 2 tool updatesv0.1.0
    • First observedexplain_command
    • First observedrun_command

TDQS

A4.3/5.0

Scored across 2 tools

Disambiguation5/5

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.

Naming Consistency5/5

Both tools follow a consistent verb_noun pattern with snake_case (run_command, explain_command), which is clear and predictable.

Tool Count3/5

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.

Completeness2/5

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

ActivitySlowing
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

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

Related MCP Connectors

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
    21
    22
    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