Core Concepts¶
This page explains how the library is put together internally — the pieces every tool is built on top of. Understanding this makes the rest of the docs (and the source code, if you ever need to read it) much easier to follow, and it's essential reading if you plan to build a custom tool.
The design principle: tools never encode policy themselves¶
Every tool in this package (file_tool, directory_tool, etc.) is "dumb"
about security on purpose. A tool never decides for itself whether a path
is allowed, whether a permission is granted, or whether an action needs
human approval. Instead, every tool calls one function — guard() — and
either gets a green light back, or an exception it converts into a clear
string message for the agent.
This matters for two reasons:
- Consistency. Every tool enforces the exact same rules the exact same way. There's no risk of one tool having a subtly weaker path check than another.
- Extensibility. Adding a new tool never means re-implementing
sandboxing logic — you call
guard()and you're done. See Building Custom Tools.
The four building blocks¶
core/
├── security.py # SecurityContext, Permission — what's allowed
├── guard.py # the single choke point every tool calls
├── approval.py # ApprovalBackend — how humans/agents say yes/no
├── hierarchy.py # manager/worker delegation (optional, multi-agent)
└── exceptions.py # MeroToolsError and subclasses
1. SecurityContext — what's allowed¶
A SecurityContext is a plain, immutable-by-convention configuration
object: which paths/permissions/commands are allowed, and what (if
anything) needs human approval. It does not know anything about
LangChain, LangGraph, or agent hierarchies — it's deliberately kept
trivial to reason about and unit test.
from langchain_mero_tools import SecurityContext, PathEntry, Permission
ctx = SecurityContext(
name="worker_1",
paths=[
PathEntry(
path="./workspace",
slug="root",
allowed_permission=Permission.READ | Permission.WRITE,
),
],
)
SecurityContext exposes the check methods that tools call (through
guard(), below):
| Method | Purpose |
|---|---|
check_path(path) |
Resolves the path through the paths mount table and raises PathAccessDeniedError if it isn't covered by any PathEntry, or if it matches a denied_path_regex rule (or, when paths is empty, always succeeds unless denied_path_regex matches — no restriction on location otherwise). Returns the resolved Path otherwise. |
check_path_access(path, permission) |
check_path() plus a permission check for that specific path. The most specific PathEntry covering the path governs — its own allowed_permission/required_permission/denied_permission, resolved through inheritance — instead of the context-wide permissions. Returns (resolved_path, needs_approval). |
check_permission(required) |
Raises PermissionDeniedError if required isn't a subset of permissions. Used directly for path-independent checks and whenever paths is empty. |
check_command(command) |
Raises CommandDeniedError if command matches a denied-command rule, or (in allowlist mode) fails to match any allowed-command rule. |
guard() (below) calls check_path_access() for anything path-bound, so
in practice tool authors rarely call these directly. The PathEntry mount
table — letting an agent address /reports/q3.csv instead of a full host
path, with its own per-entry permission that nests and inherits down a
directory tree — is covered in full in
Security & Sandboxing.
Full reference, including path resolution rules and command pattern matching: Security & Sandboxing.
2. Permission — the permission model¶
Permission is a bitflag enum.Flag, so permissions combine with |:
from langchain_mero_tools import Permission
Permission.READ # just read
Permission.READ | Permission.WRITE # read and write
Permission.all_permissions() # READ | WRITE | EXECUTE | DELETE
Permission.from_str("read|write") # parse from a string too
Four flags exist: READ, WRITE, EXECUTE, DELETE. Each tool operation
maps to exactly one of these — e.g. file_tool's read operation requires
READ, delete requires DELETE. See each tool's page for its specific
mapping.
3. guard() — the single choke point¶
Every tool operation, before touching disk or spawning a process, calls:
from langchain_mero_tools.core.guard import guard
resolved_path = guard(
ctx,
permission=Permission.WRITE,
path="some/file.txt",
detail="write some/file.txt", # human-readable label, shown in approval prompts
)
guard() does exactly two things, in order, and raises on the first
failure:
- Path + permission check — if
pathis given, callsctx.check_path_access(path, permission), which resolves the path through thePathEntrymount table and checks the requested permission against whichever entry most specifically covers it (see Security & Sandboxing). Ifpath=None(e.g.process_tool's initial EXECUTE gate, before it has a working directory to resolve), callsctx.check_permission(permission)instead — the flat, context-wide check. - Approval check — if step 1 flagged this permission as needing
approval (a
PathEntry'srequired_permission, or the context-widerequire_approval_forwhen there's no path), builds anApprovalRequestand callsctx.approval.request(...). If no approval backend is configured, this fails safe: the action is denied, not allowed, even though the path/permission checks passed.
If ctx is None (no SecurityContext was passed to the tool factory),
guard() skips all checks and just resolves the path — this is what makes
"no context = no restrictions" work.
┌───────────────┐
tool call ───▶ │ guard() │
└───────┬───────┘
│
┌─────────────┴─────────────┐
▼ ▼
path given: path is None:
check_path_access() check_permission()
(skip both if ctx is None) + needs_approval()
│ │
└─────────────┬─────────────┘
▼ (only if flagged as needing approval)
approval.request(...)
│
allowed ──────┴────── denied
│ │
▼ ▼
tool proceeds raise MeroToolsError subclass
Every tool catches the resulting MeroToolsError at its boundary and
returns it as a plain string (e.g. "Denied: Path '...' is outside the
allowed scope [...]") instead of letting a Python traceback kill the
agent's run. See Error Handling.
4. ApprovalBackend and hierarchy.py — how "yes/no" gets decided¶
When an action needs approval, guard() doesn't know or care how that
decision gets made — it just calls ctx.approval.request(req) and expects
a bool back. Four backends ship out of the box (CLI prompt, LangGraph
interrupt(), a callback wrapper, and auto-approve/deny), and
hierarchy.py adds an optional fifth for manager/worker delegation. Full
details: Approval Workflows and
Manager/Worker Delegation.
Exceptions¶
All errors raised by the security layer share one base class,
MeroToolsError, so tools can catch everything from the security layer
with a single except:
| Exception | Raised when |
|---|---|
MeroToolsError |
Base class for all of the below. |
PathAccessDeniedError |
A path isn't covered by any PathEntry in paths (or is covered but explicitly denied_permission'd for the requested permission), or is absolute and unmatched by any entry/slug. |
PermissionDeniedError |
The requested permission isn't in the governing PathEntry's effective allowed_permission (or, with no path/no paths configured, isn't in ctx.permissions). |
CommandDeniedError |
A command matches a denied rule, or fails to match an allowlist. |
ApprovalDeniedError |
A human/backend explicitly rejected the action, or approval was required but no backend was configured. |
ApprovalTimeoutError |
No backend in a ChainedApproval could resolve the request. |
Full behavior and examples: Error Handling.
Next steps¶
- See exactly how each tool maps its operations to permissions and calls
guard(): File Tool, Directory Tool, Search Tool, Process Tool, Rsync Tool. - Go deep on
SecurityContext: Security & Sandboxing. - Build your own tool on top of
guard(): Building Custom Tools.