Metadata-Version: 2.4
Name: langchain-mero-tools
Version: 0.0.5
Summary: A collection of tools for LangChain Mero.
Author-email: Suraj Airi <surajairi.ml@gmail.com>
License: MIT license
Project-URL: Homepage, https://surajairi.github.io/langchain-mero-tools/
Project-URL: Documentation, https://surajairi.github.io/langchain-mero-tools/
Project-URL: Repository, https://github.com/surajairi/langchain-mero-tools
Project-URL: Issues, https://github.com/surajairi/langchain-mero-tools/issues
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: AUTHORS.rst
Requires-Dist: langchain>=1.3.14
Requires-Dist: pydantic>=2.13.4
Provides-Extra: langgraph
Requires-Dist: langgraph>=1.2.10; extra == "langgraph"
Dynamic: license-file

# 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

```bash
# 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

```python
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

```python
from langchain_mero_tools import SecurityContext, Permission, get_tools

ctx = SecurityContext(
    name="worker_1",
    allowed_paths=["./workspace"],          # can't touch anything outside this
    denied_paths=["./workspace/.env"],      # denied always wins over allowed
    permissions=Permission.READ | Permission.WRITE,   # no DELETE, no EXECUTE
)

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 `allowed_paths`.

Need several directories with *different* permissions, or want the agent
addressing files by a short stable name instead of a full host path? Use
`paths=[PathEntry(...)]` for a virtual mount table — e.g. `/reports/q3.csv`
instead of `/home/user/projects/acme/output/reports/q3.csv`, with its own
per-mount `allowed_permission`/`required_permission`/`denied_permission`.
Full details: [Security & Sandboxing](https://surajairi.github.io/langchain-mero-tools/10-security-and-sandboxing/#virtual-mounts-pathentry).

## 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:

```python
ctx = SecurityContext(
    allowed_paths=["./workspace"],
    permissions=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:

```python
from langchain_mero_tools import SecurityContext, Permission, CLIApproval

ctx = SecurityContext(
    allowed_paths=["./workspace"],
    permissions=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:

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

### LangGraph `interrupt()` example

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

ctx = SecurityContext(
    allowed_paths=["./workspace"],
    permissions=Permission.WRITE,
    require_approval_for=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).

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

registry = PermissionRegistry()

manager_ctx = SecurityContext(
    name="manager_1",
    allowed_paths=["./workspace"],
    permissions=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",
    allowed_paths=["./workspace"],
    permissions=Permission.READ | Permission.WRITE,
    require_approval_for=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
  `allowed_paths`; 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

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