Security & Sandboxing¶
This page is the complete reference for SecurityContext — every field,
what it does, and how the checks actually work under the hood. If you've
read Core Concepts already, this page goes one level
deeper into just the SecurityContext/Permission/PathEntry part of
that picture.
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,
),
],
allowed_commands=[],
denied_commands=[...], # sensible default, shown below
require_approval_for=Permission.NONE,
approval=None,
)
Fields¶
| Field | Type | Default | Purpose |
|---|---|---|---|
paths |
list[PathEntry] |
[] |
The virtual mount table — see PathEntry below. This is the only mechanism for granting a context access to a location. Empty list = no location restriction at all. Whenever non-empty, exactly one entry must use slug="root". |
denied_path_regex |
list[str] |
[] |
.gitignore-style patterns checked against every path, on top of whatever any governing PathEntry also denies. Applies even when paths is empty. See Denying paths by pattern below. |
permissions |
Permission |
Permission.READ |
Default permission for any top-level PathEntry (one with no ancestor entry) that doesn't set its own allowed_permission. Also used directly for checks with no path at all (see Process Tool), and for all path checks when paths is empty. |
allowed_commands |
list[str] |
[] |
For process_tool/rsync_tool. Non-empty = allowlist mode (default-deny). |
denied_commands |
list[str] |
sensible default (below) | Always wins over allowed_commands. |
require_approval_for |
Permission |
Permission.NONE |
Default "needs approval" permission for any top-level PathEntry that doesn't set its own required_permission, and directly for path-independent checks / checks when paths is empty. |
approval |
ApprovalBackend \| None |
None |
Consulted for anything flagged as needing approval (via require_approval_for or a PathEntry's required_permission). None = those actions are auto-denied (fail-safe, not fail-open). |
name |
str |
"default" |
Label shown in error messages and approval prompts; also the identity used for manager/worker delegation. |
There is exactly one way to give a context access to a location:
paths: list[PathEntry]. There's no separate flat allowed_paths/
denied_paths list to keep in sync with it — a single PathEntry with
slug="root" covers the common "just scope me to one directory" case, and
is required whenever paths is non-empty:
ctx = SecurityContext(
paths=[
PathEntry(
path="./workspace",
slug="root",
allowed_permission=Permission.READ | Permission.WRITE,
),
],
)
PathEntry — the virtual mount table¶
A tool never needs to know a full, real filesystem path
(/home/user/projects/acme/output/reports/q3.csv). Each PathEntry maps
one real directory to a virtual slug, so a tool addresses
/reports/q3.csv instead — and carries its own permission independent of
every other entry:
from langchain_mero_tools import PathEntry, Permission, SecurityContext
ctx = SecurityContext(
paths=[
# Freely readable, but only writable behind approval, never deletable.
PathEntry(
path="/home/user/projects/acme/output/reports",
slug="reports",
allowed_permission=Permission.READ | Permission.WRITE,
required_permission=Permission.WRITE,
denied_permission=Permission.DELETE,
),
# The default mount — reachable with no slug prefix at all.
PathEntry(
path="/home/user/projects/acme/workspace",
slug="root",
allowed_permission=Permission.READ | Permission.WRITE | Permission.DELETE,
),
],
)
With this context, a tool addresses files as:
/reports/q3.csv→ resolves to/home/user/projects/acme/output/reports/q3.csvnotes.txt,/notes.txt,./notes.txt, and/root/notes.txt→ all resolve to/home/user/projects/acme/workspace/notes.txt(anything that doesn't match a registered slug — relative or absolute — falls back to the root entry, alongside its own explicit/root/...address)- The real, already-absolute path also still works directly if it happens to fall under a configured entry — you don't have to use slugs.
Fields¶
| Field | Type | Default | Purpose |
|---|---|---|---|
path |
str |
required | The real filesystem directory (or file) this entry maps to. |
slug |
str |
required | Virtual mount name. Every entry is directly addressable as /<slug>/.... Leading/trailing / are stripped, so "boo" and "/boo/" are equivalent. The slug "root" is reserved (see is_root below) — exactly one entry must use it whenever paths is non-empty. |
is_root |
bool (read-only property) |
— | True iff slug == "root" — not a separate field to set. The root entry's files need no slug prefix at all (bare "notes.txt" works, alongside the explicit "/root/notes.txt" form), and it's the addressing fallback for any virtual path — relative or absolute — that doesn't match a registered slug. |
allowed_permission |
Permission \| None |
None |
What's grantable at this entry. None means Permission.NONE for a nested entry (never inherited from a parent entry), or the context's permissions default for a top-level entry — see Nesting & inheritance below. |
required_permission |
Permission \| None |
None |
A subset of the effective allowed_permission that must additionally clear the approval backend. None means "inherit from the nearest ancestor entry" (or the context's require_approval_for default, for a top-level entry). |
denied_permission |
Permission \| None |
None |
Always wins, even over allowed_permission. None means "inherit from the nearest ancestor entry" (or empty, at the top of the chain — there's no context-level default deny). |
denied_path_regex |
list[str] |
[] |
.gitignore-style patterns scoped to this entry's subtree, cascading to every nested entry under it. See Denying paths by pattern below. |
Nesting & inheritance¶
Entries aren't independent silos — they nest by real filesystem path. If a resolved path falls under more than one entry's directory, the most specific (deepest) entry governs it. The three permission fields do not all inherit the same way once nesting is involved — this is deliberate, not an inconsistency:
allowed_permissionis never inherited from a parent entry. A nested entry is a complete, self-contained grant for its own subtree — leaving it unset meansPermission.NONE(locked out), not "whatever the parent allows." This is the fail-safe default: least privilege, explicit per entry, no surprises about what a subtree actually grants just by reading its ownPathEntry. (A top-level entry — one with no parent entry at all — still falls back to the context'spermissionsdefault if it doesn't set its own; that's a context-level default, not entry-to-entry inheritance.)required_permissionanddenied_permissiondo inherit from the nearest ancestor entry when left unset, and so on up the chain to the context'srequire_approval_fordefault. These are restrictions, not grants — a restriction set higher up (e.g. "DELETE always needs approval under here") shouldn't silently disappear just because a more specific entry was added underneath it.
So a nested entry states the permission(s) it actually grants (always, in full — nothing carries over), plus only the restriction field(s) it's actually changing:
ctx = SecurityContext(
paths=[
PathEntry(
path="/home/user/projects/acme/workspace",
slug="root",
allowed_permission=Permission.READ | Permission.WRITE,
denied_permission=Permission.DELETE,
),
# Its own complete grant (allowed_permission doesn't inherit) —
# here, READ only. denied_permission is left unset, so DELETE
# stays denied via inheritance from root even though "secrets"
# doesn't restate it.
PathEntry(
path="/home/user/projects/acme/workspace/secrets",
slug="secrets",
allowed_permission=Permission.READ,
),
],
)
ctx.check_path_access("readme.txt", Permission.WRITE) # OK — root's own grant
ctx.check_path_access("secrets/key.pem", Permission.READ) # OK — secrets' own grant
ctx.check_path_access("secrets/key.pem", Permission.WRITE) # PermissionDeniedError — not in secrets' own allowed_permission
ctx.check_path_access("secrets/key.pem", Permission.DELETE) # PathAccessDeniedError — denied_permission inherited from root
For the common "just deny this subtree, nothing else about it changes"
case, prefer denied_path_regex over a nested PathEntry — see below;
that's what it's for, and it avoids a whole extra mount just to carve out
an exception.
Re-allowing something an ancestor denied requires explicitly clearing
denied_permission on the more specific entry
(denied_permission=Permission.NONE) — setting allowed_permission alone
doesn't do it, since denied always wins over allowed regardless of which
entry each came from:
PathEntry(path="...", slug="s", allowed_permission=Permission.READ) # still denied — inherited denial wins
PathEntry(
path="...",
slug="s",
allowed_permission=Permission.READ,
denied_permission=Permission.NONE, # explicitly clears the inherited denial
)
Resolution order¶
For a requested permission on a path, once the governing (most specific) entry is found, its effective allowed/required/denied — after inheritance is resolved — is checked in this priority:
denied_permission— if the request overlaps this, it's rejected outright, full stop, regardless of what's allowed or required.required_permission— if the (non-denied) request overlaps this, it must clearctx.approvalbefore proceeding — see Requiring approval below. This is checked after confirming the permission is inallowed_permissionat all.allowed_permission— anything left over is either granted (if present here) or rejected withPermissionDeniedError(if not).
ctx.check_path_access("/reports/q3.csv", Permission.READ)
# -> (resolved_path, needs_approval=False) # READ is allowed, not required
ctx.check_path_access("/reports/q3.csv", Permission.WRITE)
# -> (resolved_path, needs_approval=True) # WRITE is allowed, but gated
ctx.check_path_access("/reports/q3.csv", Permission.DELETE)
# -> PathAccessDeniedError # DELETE is explicitly denied
How a path resolves¶
Every path passed to a tool goes through SecurityContext.check_path() /
check_path_access(). If paths is empty, this is a no-op passthrough —
Path(path).resolve(), no restriction on location (permission is then
governed directly by permissions/require_approval_for, same as a
path-independent check; denied_path_regex still applies). Otherwise:
- Already-absolute paths that literally land under a configured
entry are used as-is — you don't need slugs for paths a tool already
has in hand (e.g. one returned by a previous
directory_toollistcall). - Otherwise, resolve via virtual addressing: match the leading
/slug/...segment against a registered mount (this covers explicit/root/...addressing too, since"root"is a normal, if reserved, slug). - Anything that doesn't match a registered slug — relative or
absolute — falls back to the root entry, which is guaranteed to exist
whenever
pathsis non-empty./hero.txt,hero.txt, and./hero.txtall behave the same way. - Whichever real path results, resolution always follows symlinks and
collapses
../.before any comparison, and the final real path must be covered by some configured entry —..traversal that would land outside every configured entry is rejected (PathAccessDeniedError), whether or not the entry used for addressing "contains" the escape. This is the actual security boundary, not whether the input looked absolute.
ctx = SecurityContext(
paths=[
PathEntry(path="./workspace", slug="ws", allowed_permission=Permission.READ),
PathEntry(path="./workspace", slug="root", allowed_permission=Permission.READ),
],
)
ctx.check_path("/ws/notes.txt") # OK
ctx.check_path("/ws/../../etc/passwd") # PathAccessDeniedError — resolves outside every entry
ctx.check_path("/notes.txt") # OK — absolute, no matching slug, falls back to root
ctx.check_path("notes.txt") # OK — same, relative form
ctx.check_path("/root/notes.txt") # OK — explicit root addressing
An absolute path that isn't covered by any entry doesn't "escape" by
falling back to root — the fallback always composes a path under root's
own real directory, so the worst case is a confusing (nonexistent, or
oddly-nested) path under root, never one outside the sandbox. The ..
rejection in step 4 is what actually enforces the boundary.
Constructing a context with two entries that share a slug, more than one
slug="root" entry, or paths that's non-empty with no slug="root"
entry at all, raises ValueError immediately rather than silently picking
one or leaving addressing ambiguous.
Denying paths by pattern (denied_path_regex)¶
For "block this subtree/these file types, nothing else about it changes,"
a .gitignore-style pattern list is usually simpler than a nested
PathEntry: no extra mount, no permission fields to restate, and it reads
the way a developer already thinks about exclusions.
ctx = SecurityContext(
paths=[PathEntry(path="./workspace", slug="root", allowed_permission=Permission.READ)],
denied_path_regex=["*.env", "*.pem", "**/.git/**"],
)
ctx.check_path_access("config.env", Permission.READ) # PathAccessDeniedError
ctx.check_path_access("readme.txt", Permission.READ) # OK
Available both context-wide (SecurityContext.denied_path_regex, checked
against every path, matched relative to the root entry if one exists) and
per-entry (PathEntry.denied_path_regex, scoped to — and cascading down
through — that entry's own subtree). A match from either source denies
the path outright, for every operation, regardless of which permission was
requested — the same way a .gitignore'd file simply isn't there as far as
the tool is concerned. denied_path_regex is checked independently of,
and in addition to, denied_permission; either one alone is enough to
block a path.
Pattern syntax matches .gitignore closely:
| Pattern | Matches |
|---|---|
*.env |
any file named *.env, at any depth |
secrets/** |
everything under a secrets/ directory, at any depth |
/build |
only a top-level build (anchored — the leading / ties it to that list's own root: the context root for a context-wide pattern, or the entry's own directory for a per-entry pattern) |
!secrets/public.txt |
un-denies a path an earlier pattern in the same list matched — last matching pattern in a list wins, exactly like a real .gitignore |
PathEntry(
path="./workspace/logs",
slug="logs",
allowed_permission=Permission.READ,
denied_path_regex=["*.log", "!keep.log"], # everything denied except keep.log
)
sub_context() treats denied_path_regex the same way it treats every
other field: only narrowing (adding patterns) is allowed — a worker's
denylist may grow but never shrink relative to its parent's.
Permissions¶
Permission is a bitflag enum.Flag with four members: READ, WRITE,
EXECUTE, DELETE. Combine with |:
Permission.READ | Permission.WRITE
Permission.all_permissions() # all four
Permission.from_str("read|write") # parse from a string, comma or pipe separated
check_permission(required) raises PermissionDeniedError if required
isn't fully covered by ctx.permissions:
ctx = SecurityContext(permissions=Permission.READ | Permission.WRITE)
ctx.check_permission(Permission.READ) # OK
ctx.check_permission(Permission.DELETE) # PermissionDeniedError
Each tool operation is hard-mapped to exactly one permission (see each tool's own doc page for its table) — there's no way for a tool to silently require a different permission than documented, since the mapping lives in a plain dict at the top of each tool's module.
Command matching (process_tool / rsync_tool)¶
allowed_commands and denied_commands are lists of patterns, each
one of exactly three explicit kinds:
| Form | Kind | Matched against |
|---|---|---|
"re:<pattern>" |
regex | anywhere in the command (re.search, case-insensitive) |
contains * ? [ ] |
glob | the whole command (fnmatch, case-insensitive) |
| anything else | plain string | substring, case-insensitive |
- If
denied_commandsmatches, the command is blocked — full stop,denied_commandsalways wins. - If
allowed_commandsis non-empty, it acts as an allowlist: the command must match something in it, or it's denied (default-deny). This is the recommended mode for anything with real filesystem access. - If
allowed_commandsis empty, the default is allow (default-allow), subject only todenied_commands.
Why pattern kind is explicit¶
This is a deliberate fix for a real, non-obvious bug class: auto- detecting "is this regex or glob" by trying regex first and falling back to glob on a compile error is unsafe, because almost any glob is also a syntactically valid, unanchored regex.
Take the allowlist entry "git *". As a glob, it's meant to say "the
command matches the shape git followed by anything." But "git *" is
also a perfectly valid regex — "git" followed by zero-or-more literal
spaces — and re.search("git *", "legitimate") matches, because
"legitimate" contains the substring "git". If pattern kind were
guessed instead of declared, an allowlist meant to restrict an agent to
git-prefixed commands would silently also let through anything
containing the substring "git" anywhere — "legitimate_process",
"digit_leak_tool", etc.
So each pattern is classified once, explicitly, with no fallback guessing:
glob characters (* ? [ ]) mean glob, matched against the whole command;
an explicit re: prefix means regex, matched anywhere; anything else is a
plain literal substring. If you want denylist-style "catch this construct
anywhere," use re: with word boundaries, e.g. r"re:\bsudo\b" — not a
bare glob.
ctx = SecurityContext(
allowed_commands=["git *", "npm run *", "pytest *"], # glob: whole-command shape
)
ctx.check_command("git status") # OK — matches "git *"
ctx.check_command("legitimate_process") # CommandDeniedError — no glob match, correctly
Default denied_commands¶
If you don't override denied_commands, this sensible baseline denylist
is applied automatically:
[
r"re:\brm\s+-rf\s+/(\s|$|\*|/)", # rm -rf / (and /* and //, not just plain "/")
r"re:\bmkfs(\.\w+)?\b", # filesystem formatting
r"re:\bdd\s+if=", # raw disk writes
r"re::\(\)\{.*\};:", # fork bomb
r"re:\bshutdown\b",
r"re:\breboot\b",
r"re:\bsudo\b",
r"re:>\s*/dev/sd", # writing directly to a block device
r"re:\b(bash|sh|zsh|dash)\s+-c\b", # opaque sub-shell wrapping (see below)
r"re:\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(bash|sh|zsh|dash)\b", # curl | sh
]
Two of these deserve a note:
bash -c '...'/sh -c '...'is blocked outright, not because running a sub-shell is inherently disallowed, but because the pattern matcher only ever sees the outer command string — it can't safely parse what's inside an opaque sub-shell string. Since none of the other denylist patterns can see inside that string either, the wrapper itself is blocked as the only way to guarantee the other rules aren't bypassed.curl ... | sh/wget ... | bash— piping a downloaded script straight into a shell — is blocked as its own pattern, since it's one of the most common real-world compromise vectors and wouldn't otherwise be caught by anything else in the list.
You can pass your own denied_commands to replace this list entirely, or
extend it:
from langchain_mero_tools import SecurityContext
default_denied = SecurityContext().denied_commands # grab the defaults
ctx = SecurityContext(denied_commands=default_denied + [r"re:\bdocker\b"])
Blocklists are best-effort — allowlist + shell=False is the safer combo¶
Even with the fixes above, treat blocklist-based filtering as best-
effort. It's still string matching, not a real shell parser — it can be
patched around known bypasses but can't structurally guarantee coverage of
every possible one. 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 closes the class of bypass that
blocklists structurally can't catch (chained/piped sub-commands that the
denylist never even sees as separate strings). See
Process Tool for the full
shell=True vs shell=False tradeoff.
sub_context() — deriving a narrower context¶
SecurityContext.sub_context(**overrides) creates a derived context that
can only narrow scope, never broaden it — useful for handing a
manager's own context down to a worker with tighter limits:
manager_ctx = SecurityContext(
name="manager",
paths=[
PathEntry(
path="./workspace",
slug="root",
allowed_permission=Permission.READ | Permission.WRITE | Permission.DELETE,
),
],
permissions=Permission.READ | Permission.WRITE | Permission.DELETE,
)
worker_ctx = manager_ctx.sub_context(
name="worker_a",
paths=[
PathEntry(
path="./workspace/subdir",
slug="root",
allowed_permission=Permission.READ, # narrower: OK
),
],
permissions=Permission.READ, # narrower: OK
)
Attempting to broaden anything raises ValueError instead of silently
granting more than the parent context has — both for paths (a child
entry must resolve inside one of the parent's entries, and its effective
allowed_permission can't exceed that parent entry's) and for the
top-level permissions default:
manager_ctx.sub_context(
paths=[PathEntry(path="/etc", slug="root")],
)
# ValueError: sub_context cannot grant path '/etc' outside parent's mount table.
manager_ctx.sub_context(permissions=Permission.EXECUTE)
# ValueError: sub_context cannot grant permissions the parent lacks.
manager_ctx.sub_context(
paths=[
PathEntry(
path="./workspace",
slug="root",
allowed_permission=Permission.READ | Permission.WRITE | Permission.EXECUTE,
),
],
)
# ValueError: sub_context cannot grant permission(s) ['EXECUTE'] on './workspace'
# beyond parent '/root [root]'.
Requiring approval¶
require_approval_for marks specific permission flags as needing sign-off
from approval even when the path/permission checks alone would allow the
action — this is the default a top-level PathEntry inherits for its own
required_permission if it doesn't set one, and it's also what governs
path-independent checks / checks when paths is empty:
from langchain_mero_tools import 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(),
)
If require_approval_for is set but approval is None, those actions
are denied, not silently allowed — this is a deliberate fail-safe
default. Full details on approval backends:
Approval Workflows.
For finer-grained control, a PathEntry's own required_permission does
the same thing scoped to just that one entry's subtree, instead of the
whole context — see Nesting & inheritance above.
Both are governed by the same ctx.approval backend; a None backend
fail-safe-denies either way.
See also¶
- Core Concepts — how
SecurityContextfits into theguard()choke point every tool calls through. - Approval Workflows — the four built-in approval backends and how to write your own.
- Manager/Worker Delegation — using
sub_context()andPermissionRegistrytogether for multi-agent setups.