Skip to main content

Mero LangChain Tools

Security-conscious file, directory, search, process, and rsync tools for LangChain / LangGraph agents — with scoped permissions, human-in-the-loop approval, and manager/worker delegation, all optional and composable.

Nothing here is forced on you. Every tool works standalone with zero restrictions if you don't pass a SecurityContext. The security layer is there for when you do want a sandboxed, auditable agent.

📚 Full documentation: https://surajairi.github.io/langchain-mero-tools/

See the documentation for installation, a complete quickstart, per-tool references, the security/approval model in depth, LangGraph integration, and a guide to writing your own tools on top of the same sandboxing. This README stays a short overview.

Install

# Core package
pip install langchain-mero-tools
# or
uv add langchain-mero-tools

# With LangGraph support
pip install "langchain-mero-tools[langgraph]"
# or
uv add "langchain-mero-tools[langgraph]"

Quick start

from langchain_mero_tools import get_tools

# Fully unrestricted — same as handing the agent File/Directory/Search/
# Process/Rsync tools directly, no sandboxing.
tools = get_tools()

Scoping an agent to a directory

from langchain_mero_tools import SecurityContext, PathEntry, Permission, get_tools

ctx = SecurityContext(
    name="worker_1",
    paths=[
        PathEntry(
            path="./workspace",
            slug="root",
            allowed_permission=Permission.READ | Permission.WRITE,   # no DELETE, no EXECUTE
        ),
    ],
    # Blocks .env files anywhere under the mount, gitignore-style — no
    # extra PathEntry needed just to carve out one exception.
    denied_path_regex=[".env"],
)

tools = get_tools(ctx)   # file, directory, search, process, rsync — all scoped

Path scoping resolves symlinks/.. before checking, so ../../etc/passwd style traversal can't escape a configured PathEntry.

PathEntry is the only way to grant access to a location — no separate flat allow/deny-list, and every entry needs its own slug (the reserved slug="root" is required exactly once — it's the entry reachable with no prefix at all, and the addressing fallback for anything else that doesn't match a slug). Entries compose: give each its own slug for short, stable addressing (e.g. /reports/q3.csv instead of /home/user/projects/acme/output/reports/q3.csv), and nest one under another to scope a subtree — a nested entry's own allowed_permission is always a complete, self-contained grant (it never inherits from a parent entry), while required_permission/denied_permission do cascade down from the nearest ancestor unless explicitly overridden. Full details: Security & Sandboxing.

Restricting shell commands

process_tool and rsync_tool consult denied_commands / allowed_commands on the same SecurityContext. A sensible deny-list (fork bombs, sudo, rm -rf /, dd, mkfs, sub-shell wrapping, piping a download into a shell, ...) is on by default — override or extend it, or flip to allowlist mode for a fully locked-down agent:

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
)

Pass just denied_commands=[...] (leaving allowed_commands empty) for default-allow / explicit-deny instead.

Each entry in either list is one of three explicit kinds — the kind is never guessed, on purpose:

Form Kind Matched against
"re:<pattern>" regex anywhere in the command (re.search, case-insensitive)
contains * ? [ ] glob the whole command (case-insensitive)
anything else plain string substring, case-insensitive

This matters: a glob like "git *" is also a syntactically valid, unanchored regex — "git" followed by zero-or-more spaces — which would match as a substring anywhere (e.g. inside "legitimate" or "digit_leak") if it were run through re.search the way earlier versions of this library did. Globs are always matched against the whole command string instead, so allowed_commands=["git *"] means "the command has this shape end-to-end," not "the command contains this text somewhere." If you want denylist-style "catch this substring anywhere," use the re: prefix with word boundaries, e.g. r"re:\bsudo\b".

Treat blocklist-based filtering as best-effort even with these fixes — it's still string matching, not a real shell parser. allowed_commands (default- deny) is the safer mode for anything agent-driven with real filesystem access, and pairing it with process_tool's shell=False mode (below) closes the class of bypass that blocklists structurally can't catch.

Requiring human approval

Any permission can be routed through an approval backend before the action runs:

from langchain_mero_tools import SecurityContext, PathEntry, Permission, CLIApproval

ctx = SecurityContext(
    paths=[
        PathEntry(
            path="./workspace",
            slug="root",
            allowed_permission=Permission.READ | Permission.WRITE | Permission.DELETE,
        ),
    ],
    require_approval_for=Permission.WRITE | Permission.DELETE,
    approval=CLIApproval(),   # blocking terminal y/N prompt
)

Four backends ship out of the box, all implementing the same request(ApprovalRequest) -> bool interface:

Backend Use case
CLIApproval() local dev, blocking input() prompt
InterruptApproval() LangGraph interrupt() — pauses the graph, resumes via Command(resume=...) from your own UI/API
CallbackApproval(fn) wrap a webhook, Slack bot, DB poll, anything
AutoApprove() / AutoDeny() tests, fully-trusted contexts, fail-safe defaults

Writing your own is one method:

class MyApproval:
    def request(self, req: ApprovalRequest) -> bool:
        return my_slack_bot.ask(req.requester, req.action, req.detail)

LangGraph interrupt() example

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
from langchain_mero_tools import SecurityContext, PathEntry, Permission, InterruptApproval, make_file_tool

ctx = SecurityContext(
    paths=[
        PathEntry(
            path="./workspace",
            slug="root",
            allowed_permission=Permission.WRITE,
            required_permission=Permission.WRITE,
        ),
    ],
    approval=InterruptApproval(),
)
tool = make_file_tool(ctx)

# ... wire `tool` into a graph node, compile with a checkpointer ...
app = graph.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "t1"}}
result = app.invoke({"path": "note.txt", "content": "hi"}, config=config)
# result contains "__interrupt__" — graph is paused, nothing written yet

# Resume from your own UI once a human decides:
app.invoke(Command(resume={"approved": True}), config=config)

Manager/worker delegation

For multi-agent setups, a manager agent can auto-approve worker requests that already fall within the manager's own granted scope — without escalating every single action to a human. Anything the manager itself couldn't do gets deferred (to a human, or wherever you chain next).

from langchain_mero_tools import (
    SecurityContext, PathEntry, Permission, PermissionRegistry,
    ManagerDelegationApproval, ChainedApproval, CLIApproval,
)

registry = PermissionRegistry()

manager_ctx = SecurityContext(
    name="manager_1",
    paths=[
        PathEntry(
            path="./workspace",
            slug="root",
            allowed_permission=Permission.READ | Permission.WRITE,
        ),
    ],
)
registry.register_context("manager_1", manager_ctx)
registry.grant_delegation("manager_1", can_approve_for=["worker_a", "worker_b"])

worker_ctx = SecurityContext(
    name="worker_a",
    paths=[
        PathEntry(
            path="./workspace",
            slug="root",
            allowed_permission=Permission.READ | Permission.WRITE,
            required_permission=Permission.WRITE,
        ),
    ],
    approval=ChainedApproval([
        ManagerDelegationApproval(registry, manager="manager_1"),  # tries manager first
        CLIApproval(),                                              # falls back to a human
    ]),
)

If worker_a requests a WRITE within manager_1's own scope, the manager decides on the spot. If it exceeds the manager's own permissions (or the manager wasn't delegated authority over that worker), it falls through to the next backend in the chain — here, a human via CLI.

Tools reference

All tools are built with make_*_tool(ctx=None) or bundled via get_tools(ctx, include=[...]).

  • file_tool — read, write (overwrite/append), edit (find/replace, accepts a list of paths for multi-file edits in one call), copy, move, delete.
  • directory_tool — list (tree view; depth, glob, ignore, include_hidden), create, copy, move, delete (always recursive).
  • file_search_tool — files, content. Backed by ripgrep if installed, then grep, then a pure-Python fallback — works with zero system deps either way. max_results is a real global cap regardless of which engine is used: matching subprocess output is streamed and the process is terminated as soon as the cap is hit, rather than letting an external tool scan/emit more than needed on a large tree.
  • process_tool — runs a command; working directory pinned inside the resolved PathEntry (via ctx.default_cwd()); command string checked against allowed_commands / denied_commands. make_process_tool(ctx, shell=True|False): shell=True (default, backward compatible) runs through a real shell — pipes/&&/redirection all work, but only the outer command string is checked, so chained sub-commands aren't individually validated. shell=False splits the command with shlex and execs it directly with no shell at all: ;, |, &&, and backticks are inert (just literal argument text), at the cost of no pipes/chaining/redirection. Recommended for agents with real filesystem access. Output (stdout/stderr) is truncated at 20,000 chars each to avoid blowing up the agent's context.
  • rsync_tool — syncs a source directory to a destination; both paths independently checked against the SecurityContext (so it can't be used to move files outside the sandbox in either direction). Uses rsync if installed, otherwise a Python copy-based fallback.

Development

pip install -e ".[langgraph]" --group dev
pytest
ruff check src/ tests/

Metadata

Release files for langchain-mero-tools 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for langchain-mero-tools 0.1.0
File Size Uploaded
langchain_mero_tools-0.1.0.tar.gz 48.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for langchain-mero-tools 0.1.0
File Interpreter ABI Platform
langchain_mero_tools-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 89.7 kB

Release files / langchain_mero_tools-0.1.0.tar.gz

Download URL langchain_mero_tools-0.1.0.tar.gz
Size 48.8 kB
Tags Source
SHA-256 checksum
How to use checksums
e98b0abfb10e2b5c90c06ca653acc4b5622bc6105387729e508a785e97b678c3
BLAKE2b-256 checksum
How to use checksums
8607a89a073c5bf98510ddab7da334efff7d88e3e333e0507e696555a470bec9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / langchain_mero_tools-0.1.0-py3-none-any.whl

Download URL langchain_mero_tools-0.1.0-py3-none-any.whl
Size 40.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
792d63a396812e27331326275b597f1b8126be0ac265b63f123641f487f8112e
BLAKE2b-256 checksum
How to use checksums
7fc6b495e49bc4ea6e05ca518c35d4b11b824d9209c41632bc629241f183e602
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

0.0.5

2 release files

0.0.2

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page