Skip to content

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:

  1. 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.
  2. 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:

  1. Path + permission check — if path is given, calls ctx.check_path_access(path, permission), which resolves the path through the PathEntry mount table and checks the requested permission against whichever entry most specifically covers it (see Security & Sandboxing). If path=None (e.g. process_tool's initial EXECUTE gate, before it has a working directory to resolve), calls ctx.check_permission(permission) instead — the flat, context-wide check.
  2. Approval check — if step 1 flagged this permission as needing approval (a PathEntry's required_permission, or the context-wide require_approval_for when there's no path), builds an ApprovalRequest and calls ctx.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