Skip to content

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.csv
  • notes.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_permission is never inherited from a parent entry. A nested entry is a complete, self-contained grant for its own subtree — leaving it unset means Permission.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 own PathEntry. (A top-level entry — one with no parent entry at all — still falls back to the context's permissions default if it doesn't set its own; that's a context-level default, not entry-to-entry inheritance.)
  • required_permission and denied_permission do inherit from the nearest ancestor entry when left unset, and so on up the chain to the context's require_approval_for default. 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:

  1. denied_permission — if the request overlaps this, it's rejected outright, full stop, regardless of what's allowed or required.
  2. required_permission — if the (non-denied) request overlaps this, it must clear ctx.approval before proceeding — see Requiring approval below. This is checked after confirming the permission is in allowed_permission at all.
  3. allowed_permission — anything left over is either granted (if present here) or rejected with PermissionDeniedError (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:

  1. 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_tool list call).
  2. 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).
  3. Anything that doesn't match a registered slug — relative or absolute — falls back to the root entry, which is guaranteed to exist whenever paths is non-empty. /hero.txt, hero.txt, and ./hero.txt all behave the same way.
  4. 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_commands matches, the command is blocked — full stop, denied_commands always wins.
  • If allowed_commands is 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_commands is empty, the default is allow (default-allow), subject only to denied_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 SecurityContext fits into the guard() 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() and PermissionRegistry together for multi-agent setups.