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.
shell=False (recommended for real agents)¶
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:
Working directory¶
The working directory is always pinned inside the sandbox:
- If
cwdis passed explicitly, it's checked with the sameguard()path - permission check as everything else — it must be covered by a
PathEntryinctx.paths(or the flat scope, ifpathsis empty). - Otherwise,
ctx.default_cwd()is used: the root entry (slug="root") inctx.paths— guaranteed to exist wheneverpathsis non-empty — or the tool's own process working directory (Path.cwd()) if there's no context or nopathsconfigured.
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)¶
Run something with piping (requires shell=True)¶
Override the working directory for one call¶
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_commandsreference, including the default denylist and exactly how pattern matching (literal/glob/regex) works. - Approval Workflows — gate
EXECUTEbehind human approval withrequire_approval_for=Permission.EXECUTE.