Error Handling¶
This page explains how failures flow from the security layer all the way to what an agent (or you) actually sees, and documents the full exception hierarchy.
The core design decision: tools return strings, they don't raise¶
Every tool in this package returns a plain string for both success and expected failure. A denied path, a missing file, a rejected approval, an unknown operation — none of these raise an exception out of a tool call. This is deliberate: an LLM agent reads tool output as text and can react to it (retry with a different path, ask the user for clarification, give up gracefully) — a raised exception either crashes the agent's run entirely or gets surfaced as an opaque traceback the model has no good way to interpret.
Genuine bugs (a real Python error unrelated to security/validation) are
not swallowed — only MeroToolsError and its subclasses are caught at
each tool's boundary. Anything else still raises normally, so real bugs
stay visible in development and don't get silently reported to the agent
as if they were a permission problem.
The exception hierarchy¶
MeroToolsError (base class for everything below)
├── PathAccessDeniedError # path not covered by any PathEntry, or explicitly denied_permission'd
├── PermissionDeniedError # missing READ/WRITE/EXECUTE/DELETE
├── CommandDeniedError # command blocked by denylist, or fails allowlist
├── ApprovalDeniedError # human/backend rejected the action, or no backend configured
└── ApprovalTimeoutError # ChainedApproval: no backend in the chain could resolve
All are defined in core/exceptions.py and all inherit from
MeroToolsError, so any tool that wants to catch "anything the security
layer might raise" only needs one except MeroToolsError as e: clause.
| Exception | Raised by | Typical message |
|---|---|---|
PathAccessDeniedError |
SecurityContext.check_path() / check_path_access() |
"Path 'x' (resolves to 'y') is not covered by any PathEntry in context 'name'.", "...is an absolute path that isn't covered by any configured PathEntry, and its leading segment doesn't match a mounted slug (...).", or "...is denied permission(s) ['WRITE'] by '/slug' in context 'name'." |
PermissionDeniedError |
SecurityContext.check_permission() / check_path_access() |
"Context 'name' lacks permission(s) ['DELETE'] (has: ['READ', 'WRITE'])." or "'/slug' in context 'name' lacks permission(s) ['DELETE'] for 'x' (allows: ['READ', 'WRITE'])." |
CommandDeniedError |
SecurityContext.check_command() |
"Command 'x' is blocked by denied-command rule '...' in context 'name'." or "...does not match any allowed-command rule ... (allowlist mode: default-deny)." |
ApprovalDeniedError |
guard(), when approval is required and rejected (or missing) |
"Action 'WRITE: path' was not approved for context 'name'." or "...requires approval for ['WRITE'] but no approval backend is configured (fail-safe deny)." |
ApprovalTimeoutError |
ChainedApproval |
"No backend in chain could resolve approval for 'WRITE: path'." |
What a tool call actually returns¶
Every tool's internal _run(...) function wraps its logic in a try/except
MeroToolsError, and formats the caught exception as a string prefixed
with "Denied: ":
So from the calling code's (or agent's) point of view:
file_tool.invoke({"operation": "delete", "path": "notes.txt"})
# "Denied: Context 'worker_1' lacks permission(s) ['DELETE'] (has: ['READ', 'WRITE'])."
Separately, expected non-security failures (a file that doesn't exist,
a required argument that's missing) return a plain "Error: ..." string —
these never touch the exception hierarchy at all, since they're not
security decisions:
file_tool.invoke({"operation": "read", "path": "missing.txt"})
# "Error: 'missing.txt' is not a file or does not exist."
The "Denied: " vs "Error: " prefix is a useful signal if you're
building UI or logic around tool output: "Denied: " means the security
layer stopped the action; "Error: " means the action was allowed but
failed for an ordinary reason.
Handling errors yourself, outside a tool¶
If you're calling SecurityContext methods directly (e.g. building a
custom check outside the normal tool flow), the exceptions raise normally
and you catch them yourself:
from langchain_mero_tools import SecurityContext, PathEntry, Permission
from langchain_mero_tools.core.exceptions import PathAccessDeniedError
ctx = SecurityContext(
paths=[PathEntry(path="./workspace", slug="root", allowed_permission=Permission.READ)],
)
try:
ctx.check_path("/etc/passwd")
except PathAccessDeniedError as e:
print(f"blocked: {e}")
See also¶
- Core Concepts — where these exceptions originate
in the
guard()flow. - Building Custom Tools — the exact
try/except MeroToolsErrorpattern to follow in a new tool. - FAQ & Troubleshooting — common error messages and what they mean.