Skip to content

Process Tool

process_tool runs a shell command and returns its stdout, stderr, and exit code. This is the highest-risk tool in the package — running arbitrary commands with no limits is not something this package hands out quietly by default, so read this whole page before using it in anything that isn't a throwaway local script.

from langchain_mero_tools import make_process_tool

process_tool = make_process_tool(ctx, shell=True)   # see shell mode section below

Unlike the file/directory/search tools, this one is meaningless without a SecurityContext in any real deployment. Building it with ctx=None still works — but it logs a warnings.warn(...) at build time, because that configuration means "run anything, no limits."

Input schema

{
    "command": str,       # required — the shell command to execute
    "timeout": int,       # default 30 — seconds before the command is killed
    "cwd": str | None     # optional working directory (must be within allowed scope)
}

Requires Permission.EXECUTE.

shell=True vs shell=False

This is the single most important decision when building this tool, so it gets its own section.

make_process_tool(ctx, shell=True)   # default, backward compatible
make_process_tool(ctx, shell=False)  # recommended for agents with real filesystem access

shell=True (default)

Runs the command through a real shell: subprocess.run(command, shell=True). Pipes, &&, > redirection, and $() substitution all work — this is the mode most people expect from "run a shell command."

The tradeoff: an agent can chain commands (ls; rm -rf /workspace/*), and only the literal outer command string is checked against denied_commands/allowed_commands — sub-commands inside a pipe or chain are not individually validated. The default denied_commands list (see Security & Sandboxing) patches around the known bypasses (fork bombs, sudo, rm -rf /, bash -c '...' wrapping, curl | sh, etc.), but blocklist-based shell filtering is fundamentally best-effort, not a hard guarantee. String matching is not a real shell parser.

Splits the command with shlex.split and execs it directly: subprocess.run(argv, shell=False). There is no shell at all to interpret ;, |, &&, or backticks — a string like "ls; rm -rf /" is just a single, invalid argument to ls, not two commands. This closes an entire bypass class structurally, not by pattern-matching around it.

The tradeoff: no pipes, no redirection, no chaining. An agent that wants grep foo | wc -l needs two separate tool calls instead (one grep, feed the result back, then wc) — or you use file_search_tool for the search half instead of shelling out to grep at all.

Recommendation: pair shell=False with allowed_commands (allowlist mode) for any agent that has real filesystem access. This closes the class of bypass that blocklists structurally can't catch. shell=True should be an explicit, informed opt-in, not the default choice for a production agent — see Security & Sandboxing for how to configure an allowlist.

ctx = SecurityContext(
    paths=[
        PathEntry(
            path="./workspace",
            slug="root",
            allowed_permission=Permission.READ | Permission.WRITE | Permission.EXECUTE,
        ),
    ],
    allowed_commands=["git *", "npm run *", "pytest *"],  # allowlist: default-deny
)
process_tool = make_process_tool(ctx, shell=False)

Setting this through get_tools() instead of the factory directly:

tools = get_tools(ctx, process_shell=False)

Working directory

The working directory is always pinned inside the sandbox:

  1. If cwd is passed explicitly, it's checked with the same guard() path
  2. permission check as everything else — it must be covered by a PathEntry in ctx.paths (or the flat scope, if paths is empty).
  3. Otherwise, ctx.default_cwd() is used: the root entry (slug="root") in ctx.paths — guaranteed to exist whenever paths is non-empty — or the tool's own process working directory (Path.cwd()) if there's no context or no paths configured.

This means cd ../../etc && rm something style escapes are still caught — even in shell=True mode — because any cwd argument goes through the normal path check, and relative paths inside the command are resolved relative to a working directory that's already inside the sandbox.

Output handling

  • stdout and stderr are each independently truncated at 20,000 characters, with a [...stdout truncated at 20000 chars...] marker appended if truncation happened — this stops a chatty command from blowing up the agent's context window.
  • The response always ends with [exit code: N].
  • A command that exceeds timeout (default 30s) is killed and returns "Error: command timed out after {timeout}s.".

Examples

Run a test suite (shell=False, allowlist mode)

process_tool.invoke({"command": "pytest tests/ -v"})

Run something with piping (requires shell=True)

process_tool.invoke({"command": "cat access.log | grep 404 | wc -l"})

Override the working directory for one call

process_tool.invoke({
    "command": "git status",
    "cwd": "./workspace/my-repo",
})

Error responses

process_tool.invoke({"command": "sudo rm -rf /"})
# "Denied: Command 'sudo rm -rf /' is blocked by denied-command rule 're:\bsudo\b' in context '...'."

process_tool.invoke({"command": "npm install left-pad"})
# (in allowlist mode with allowed_commands=["git *"])
# "Denied: Command 'npm install left-pad' does not match any allowed-command rule
#  in context '...' (allowlist mode: default-deny)."

In shell=False mode, a command that can't be tokenized (bad quoting) returns a clear message rather than a cryptic Python exception:

process_tool.invoke({"command": "echo \"unterminated"})
# "Error: could not parse command for non-shell execution (...). This tool is running
#  in shell=False mode, so the command is split with shlex rather than interpreted
#  by a shell — check quoting, or use a SecurityContext-scoped shell=True tool if
#  you genuinely need shell features."

See also

  • Security & Sandboxing — the full allowed_commands/denied_commands reference, including the default denylist and exactly how pattern matching (literal/glob/regex) works.
  • Approval Workflows — gate EXECUTE behind human approval with require_approval_for=Permission.EXECUTE.