Skip to content

Permissions

Permissions are declarative allow / deny / interrupt rules evaluated before a tool runs. They answer: may this agent read or write this path, or run this command?

Rules go on AgentConfig as filesystem_permissions and execution_permissions. The SDK registers the matching BeforeToolCallEvent plugins automatically — do not attach permission plugins by hand.

They do not isolate a process or give the agent its own filesystem (Sandbox does), persist conversation history (Sessions do), or replace the pause / resume API (Interrupts do). A rule with mode="interrupt" raises an interrupt so a human can approve or reject the call.

Default model is Amazon Bedrock

Agent() with no model= uses Amazon Bedrock (Claude Sonnet 4.6 in us-west-2). Configure AWS credentials (aws configure or AWS_* env vars), or pass an explicit model — see Installation and Model Providers.

Permissions vs Sandbox vs Interrupts

FeatureWhat it isTypical question
PermissionsFirst-match rules on paths and commandsMay this tool call run?
SandboxSeparate workspace and backend for plugin toolsWhere do files and shell run?
InterruptPause / resume API for human reviewDid a human approve this pause?

Rules to remember:

  1. A path or command with no matching rule is allowed — the default is permissive.
  2. Rules are evaluated in list order, so put deny rules before broad allow rules.
  3. Multi-path and multi-command calls are all-or-nothing: deny > interrupt > allow.
  4. Sandbox read_file / write_file / list_dir are not gated. Sandbox execute is.
  5. Permissions are a guardrail, not isolation — pair them with a Sandbox for containment.

What is gated

Rule typeTools evaluatedMatching
FilesystemHost file_read, file_write, editorGlob path patterns per read / write operation
ExecutionHost shell, python_repl, and sandbox executeRegex against the command (or REPL code), plus optional cwd checks

Everything else is ungated: custom @tool functions (unless they reuse a gated tool name), http_request, retrieve, and the sandbox workspace file tools. Constrain sandbox file access with sandbox attach mode and workspace sync policy instead.

Quickstart

python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.permissions import deny_secrets, deny_destructive, preset, execution_preset

agent = Agent(
    tools=[...],  # include file_read / file_write / editor / shell as needed
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[*deny_secrets(), *preset("workspace_only")],
        execution_permissions=[*deny_destructive(), *execution_preset("require_workspace_cwd")],
    ),
)

Order matters: deny_secrets() before workspace_only() blocks .env even though it sits under /workspace.

How it works

  1. On BeforeToolCallEvent, the plugin maps the call to an operation and paths, or to a tool key and commands.
  2. Each path or command is matched against the rule list; the first matching rule decides that item.
  3. Outcomes for a multi-path or multi-command call are aggregated with strict priority: deny > interrupt > allow. One deny wins the whole call — including over an interrupt on a sibling path or command. No human is asked.
  4. Deny (and interrupt reject) cancel the tool. An optional DenyPolicy then continues the loop, stops the invocation, or steers the model.

Filesystem rules

python
from elsai.permissions import FilesystemPermission

FilesystemPermission(
    operations=("read", "write"),
    paths=("**/.env", "/workspace/secrets/**"),
    mode="deny",  # allow | deny | interrupt
    name="deny_env",
    reason="Secrets must not be read or written",
    guidance="Ask the user instead of reading .env.",
)
FieldDescription
operations"read" and/or "write" (must be non-empty)
pathsGlob patterns (must be non-empty). **, *, and ? are supported
mode"allow", "deny", or "interrupt"
nameIdentifier for this rule. Stored as matched_rule on the audit event and included in cancel messages. When unset it falls back to reason, then to a generated label such as deny:**/.env — grep logs for that fallback if you never set name
reasonThe message text. On mode="deny" this is the error the model receives; on mode="interrupt" it is the approval prompt a human reads as interrupt.reason. When unset, an automatic message is generated (read denied for /etc/passwd (rule: deny_env))
guidanceAn instruction for the model, used only when DenyPolicy(on_deny="guide") is set. Say what the model should do instead; without it the model only learns that the call failed

Path mapping

ToolOperationPaths
file_readreadpath (comma-separated) and comparison_path when mode="diff"
file_writewritepath
editorread for view, find_line; write for create, str_replace, pattern_replace, insert, undo_editpath

Relative paths resolve against AgentConfig.workspace_root; absolute paths are matched as-is.

Tool-input globs are expanded before any rule runs. file_read with path="*.env" becomes the real files that match (for example /home/user/project/.env), and those resolved paths are what your rule patterns see. Write rules against the files (**/.env), not against the literal string *.env. If the glob matches nothing on disk, the unevaluated glob string is kept as a single path and matched as-is.

The logical prefix /workspace in a pattern maps to workspace_root, which keeps rules portable across machines:

python
AgentConfig(
    workspace_root="/home/user/project",
    filesystem_permissions=[
        FilesystemPermission(
            operations=("read", "write"),
            paths=("/workspace/**",),   # matches /home/user/project/**
            mode="allow",
        ),
    ],
)

Because a denied path fails the whole call, a single file_read covering several paths is all-or-nothing — including when another path would have paused for approval:

python
# path="README.md,.env" → the .env deny cancels the entire read,
# including README.md, which would have been allowed on its own.
agent("Read README.md and .env in one call")

# path="key.pem,.env" with interrupt on *.pem and deny on .env
# → denied. The pem interrupt is never raised.
agent("Read key.pem and .env in one call")

Filesystem presets

python
from elsai.permissions import preset, workspace_only, read_only, deny_secrets

preset("workspace_only")  # same as workspace_only()
preset("read_only")
preset("deny_secrets")
PresetBehaviour
workspace_onlyAllow read+write under /workspace/**; deny everything else
read_onlyAllow read under /workspace/**; deny all writes and reads outside workspace
deny_secretsDeny read+write on .env, secrets/, credentials, *.pem, id_rsa*

Presets are ordinary rule lists, so you compose them with * and the usual ordering applies — the stricter list goes first:

python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.permissions import deny_secrets, workspace_only
from elsai_tools.file_read import file_read
from elsai_tools.file_write import file_write

agent = Agent(
    tools=[file_read, file_write],
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[*deny_secrets(), *workspace_only()],
    ),
)

agent("Read README.md")            # allowed — inside the workspace
agent("Read .env")                 # denied by deny_secrets
agent("Read /etc/passwd")          # denied by workspace_only
agent("Write notes/plan.md")       # allowed — inside the workspace

Swapping the two lists silently breaks the policy: workspace_only() allows everything under the workspace, so a later deny_secrets() would never be reached for a .env file that lives there.

Execution rules

python
from elsai.permissions import ExecutionPermission

ExecutionPermission(
    tools=("shell", "execute"),
    patterns=(r"^\s*rm\b", r"^\s*dd\b"),
    mode="deny",
    name="block_rm",
    reason="Destructive commands are blocked",
    guidance="Use a non-destructive command or ask the user.",
)
FieldDescription
tools"shell", "python_repl", and/or "execute"
patternsRegexes matched with re.search() against the command or REPL code. Required unless require_workspace_cwd=True
mode"allow", "deny", or "interrupt"
require_workspace_cwdIf True, this rule is a cwd gate: outside workspace_root it fires immediately (mode applies); inside workspace the rule is skipped and its patterns never run
nameIdentifier for this rule. Stored as matched_rule on the audit event and included in cancel messages. When unset it falls back to reason, then to a generated label such as deny:^\s*rm\b
reasonThe message text. On mode="deny" this is the error the model receives; on mode="interrupt" it is the approval prompt a human reads as interrupt.reason. When unset, an automatic message is generated (shell command blocked: 'rm -rf /' (rule: block_rm))
guidanceAn instruction for the model, used only when DenyPolicy(on_deny="guide") is set. Say what the model should run instead of the blocked command

Patterns use search(), not match(), so an unanchored pattern matches anywhere in the command — which is usually not what you want:

python
r"rm"          # also matches "npm run build" and "chmod -R"
r"^\s*rm\b"    # matches only a command that starts with rm

\b marks a word boundary, so ^\s*rm\b matches rm -rf build but not rmdir. The same pattern works for REPL code, where you match on identifiers instead of executables:

python
ExecutionPermission(
    tools=("python_repl",),
    patterns=(r"os\.system\b", r"subprocess\b"),
    mode="deny",
    name="deny_repl_shell_escape",
    reason="os.system / subprocess are blocked in python_repl",
)

require_workspace_cwd is a separate first pass, not an AND with patterns. Before any command is regex-matched, every rule with require_workspace_cwd=True is checked for the named tools:

  • Cwd outside workspace_root → that rule fires immediately (mode applies). patterns on the same rule are not consulted.
  • Cwd inside workspace, or unset (treated as in-workspace) → the cwd pass skips the rule. In the pattern pass, rules with require_workspace_cwd=True are skipped entirely, so patterns on that same rule never run.

Put cwd gating on its own rule (empty patterns is allowed only here). Put command regexes on a second rule without require_workspace_cwd. It only covers the tools named on that rule, so a rule listing execute leaves host shell unrestricted:

python
ExecutionPermission(
    tools=("shell", "execute"),
    patterns=(),                  # allowed to be empty here
    require_workspace_cwd=True,
    mode="deny",
    name="cwd_must_be_workspace",
    reason="Commands must run inside the workspace",
)

# work_dir="/home/user/project/src" → cwd pass skips; later pattern rules decide
# work_dir="/tmp"                   → denied here, even if the command is `echo hi`
# work_dir omitted                  → treated as in-workspace

Command mapping

ToolWhat is matchedWorking directory
shellcommand — string, list of strings, or JSON list stringwork_dir
python_replcode
executecommandcwd

A list of commands is evaluated per item and aggregated with the same deny > interrupt > allow rule, so one blocked segment cancels the whole call — including over an interrupt on another segment:

python
# ["echo hello", "rm -rf build"] → denied; the echo does not run either.
agent('Run: echo hello, then rm -rf build')

# ["curl https://example.com", "rm -rf build"] with interrupt on curl and deny on rm
# → denied. The curl interrupt is never raised.

Execution presets

python
from elsai.permissions import execution_preset

execution_preset("deny_destructive")
execution_preset("interrupt_all_shell")
execution_preset("interrupt_all_execute")
execution_preset("allow_safe_readonly_shell")
execution_preset("require_workspace_cwd")
execution_preset("host_shell_disabled")
PresetBehaviour
deny_destructiveDeny rm, mv, mkfs, dd, and shell redirects on shell and execute (not python_repl)
interrupt_all_shellInterrupt every host shell call for human approval
interrupt_all_executeInterrupt every sandbox execute call
allow_safe_readonly_shellAllow ls, cat, head, tail, pwd, echo, read-only git, and pytest; deny other shell
require_workspace_cwdDeny shell / execute when cwd is outside workspace_root
host_shell_disabledDeny all shell and python_repl (steer toward sandbox execute)

A common production combination blocks destructive commands and pins the working directory:

python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.permissions import deny_destructive, require_workspace_cwd
from elsai_tools.shell import shell

agent = Agent(
    tools=[shell],
    config=AgentConfig(
        workspace_root="/home/user/project",
        execution_permissions=[*deny_destructive(), *require_workspace_cwd()],
    ),
)

agent('Run: pytest -q')              # allowed
agent('Run: rm -rf build')           # denied by deny_destructive
agent('Run: ls, with work_dir /tmp') # denied by require_workspace_cwd

allow_safe_readonly_shell() inverts the approach — it allowlists a handful of read-only commands and denies everything else, which is safer than enumerating dangerous ones:

python
from elsai.permissions import allow_safe_readonly_shell

AgentConfig(execution_permissions=[*allow_safe_readonly_shell()])

# ls, cat, head, tail, pwd, echo, git status/diff/log, pytest → allowed
# curl, pip install, rm                                       → denied

Note that deny_destructive() covers shell and execute but not python_repl — a model can still call os.system("rm -rf ...") through the REPL. Add host_shell_disabled() or a REPL-specific pattern rule when python_repl is in the tool list.

Modes and approval

ModeEffect
allowTool runs (unless another path or command in the same call is deny/interrupt)
denyTool is cancelled before execution
interruptAgent pauses with stop_reason="interrupt". Resume with response "approve" to run the tool; any other response is treated as deny

Approving an interrupt rule

mode="interrupt" means ask a human first. Instead of running or blocking the tool, the agent stops mid-turn and hands control back to your code. Nothing has executed yet at that point — the tool call is held, waiting for a decision.

1. Write the rule

A rule that pauses on one sensitive file:

python
from elsai import Agent
from elsai.agent import AgentConfig
from elsai.permissions import FilesystemPermission
from elsai_tools.file_read import file_read

agent = Agent(
    tools=[file_read],
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[
            FilesystemPermission(
                operations=("read",),
                paths=("**/approval-required.txt",),
                mode="interrupt",
                name="approve_sensitive_read",
                reason="Allow reading approval-required.txt?",
            )
        ],
    ),
)

result = agent("Read approval-required.txt and summarize it")

result.stop_reason      # "interrupt" — the agent paused instead of finishing
result.interrupts[0].id      # e.g. "5f2c..." — the handle you answer with
result.interrupts[0].name    # "fs_permission" — which layer paused
result.interrupts[0].reason  # "Allow reading approval-required.txt?" — show this to your reviewer

2. Read the interrupt

Each paused call becomes an Interrupt with three fields you care about: id identifies which pause you are answering, reason is the prompt to show a human, and name tells you what kind of pause it is.

3. Check the interrupt name

Permissions are not the only feature that can pause an agent — your own hooks can raise interrupts too, with names they choose. Permission pauses always use one of two fixed names:

ConstantValueRaised by
FS_PERMISSION_INTERRUPT_NAME"fs_permission"An interrupt-mode FilesystemPermission
EXEC_PERMISSION_INTERRUPT_NAME"execution_permission"An interrupt-mode ExecutionPermission

Checking the name lets you handle permission approvals separately from your own interrupts in the same loop. The constants are plain strings, so interrupt.name == "fs_permission" works too — importing them just protects you from typos.

4. Approve or reject

Resume by calling the agent again with one response per interrupt. The response "approve" (exported as FS_PERMISSION_APPROVE_RESPONSE and EXEC_PERMISSION_APPROVE_RESPONSE, both "approve") runs the held tool call. Anything else counts as a rejection, so there is no separate deny keyword — "reject", "no", or an empty string all cancel the tool exactly as a deny rule would.

python
from elsai.permissions import (
    EXEC_PERMISSION_INTERRUPT_NAME,
    FS_PERMISSION_APPROVE_RESPONSE,
    FS_PERMISSION_INTERRUPT_NAME,
)

result = agent("Read approval-required.txt and summarize it")

# One turn can pause several times, and approving may reveal the next pause,
# so keep resuming until the agent finishes normally.
while result.stop_reason == "interrupt":
    responses = []
    for interrupt in result.interrupts:
        if interrupt.name in (FS_PERMISSION_INTERRUPT_NAME, EXEC_PERMISSION_INTERRUPT_NAME):
            approved = input(f"{interrupt.reason} [y/N] ") == "y"
            responses.append({
                "interruptResponse": {
                    "interruptId": interrupt.id,
                    "response": FS_PERMISSION_APPROVE_RESPONSE if approved else "reject",
                }
            })
    result = agent(responses)   # resume with the collected decisions

print(result)

The while loop matters: approving one call lets the turn continue, and the agent may immediately hit another interrupt rule. A single if would leave the agent paused.

A rejection is a deny, so it flows into DenyPolicy like any other deny. See Interrupts for interrupts raised outside permissions.

Interrupts need a caller that can answer

mode="interrupt" only works when something is driving the loop — a CLI, a UI, or an approval queue. In an unattended job there is nobody to respond, so use deny instead.

DenyPolicy

A rule decides whether a tool runs. DenyPolicy decides what happens after a block: keep going, stop the run, or tell the model what to do instead.

Without it, the tool is cancelled and the model sees a PermissionDenied: / ExecutionDenied: error — which often leads it to retry variations that can never succeed. Set it once on AgentConfig and it covers every filesystem and execution deny, including interrupt rejections.

FieldDefaultDescription
on_deny"continue"What to do after a deny: "continue", "stop", or "guide"
on_budget_exceeded"stop"What to do once a budget below is exceeded
max_denies_per_invocationNoneCap total denials in one agent(...) call
max_denies_per_toolNoneCap denials per tool name, e.g. {"shell": 3}
max_denies_same_keyNoneCap repeats of the same path set or command
default_guidanceNoneSteer text used when a rule has no guidance
budget_guidanceNoneSteer text used when a budget is exceeded

continue — let the model recover

The default. Nothing to configure. Use it when the model has an obvious alternative.

python
from elsai.permissions import deny_secrets

agent = Agent(
    tools=[file_read],
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[*deny_secrets()],
        # no deny_policy → on_deny="continue"
    ),
)

# The .env read is cancelled; the model sees the error and answers from other files.
result = agent("Read .env, then summarize README.md")

stop — end the run on the first deny

Use it when a blocked call means the task is invalid and the caller should hear about it.

python
from elsai.permissions import DenyPolicy, deny_secrets

agent = Agent(
    tools=[file_read],
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[*deny_secrets()],
        deny_policy=DenyPolicy(on_deny="stop"),
    ),
)

result = agent("Read .env and print the token")
result.stop_reason  # "permission_denied" — distinguishes a block from normal completion

guide — redirect the model

Cancels the tool and attaches an instruction. Text is taken from the first of these that is set: the rule's guidance, the rule's reason, default_guidance, then a generic fallback. Put specific advice on the rule and a catch-all on the policy.

python
from elsai.permissions import DenyPolicy, FilesystemPermission

agent = Agent(
    tools=[file_read],
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[
            FilesystemPermission(
                operations=("read",),
                paths=("**/.env",),
                mode="deny",
                name="deny_env",
                reason="Secrets must not be read",
                guidance="Read config.example.env instead, or ask the user for the value.",
            )
        ],
        deny_policy=DenyPolicy(
            on_deny="guide",
            default_guidance="Use an allowlisted path, or ask the user.",
        ),
    ),
)

# The model is told to try config.example.env rather than retrying .env.
result = agent("Read .env and tell me the database host")

Interrupt-mode reason is skipped in that chain — it is a question for a human ("Allow reading this file?"), not an instruction for a model. Set guidance on interrupt rules when using on_deny="guide"; the SDK warns if you forget.

Budgets — cap retries in one turn

Budgets count denials within a single agent(...) call and reset on the next. They are inclusive: with max_denies_per_invocation=2, the first two denials use on_deny and the third uses on_budget_exceeded.

python
from elsai.permissions import DenyPolicy, deny_destructive

agent = Agent(
    tools=[shell],
    config=AgentConfig(
        workspace_root="/home/user/project",
        execution_permissions=[*deny_destructive()],
        deny_policy=DenyPolicy(
            on_deny="guide",              # first two denials: steer the model
            max_denies_per_invocation=2,
            on_budget_exceeded="stop",    # third denial: give up on this turn
            budget_guidance="Deny budget reached. Stop retrying blocked commands.",
        ),
    ),
)

result = agent("Delete the build directory, then try again if it fails")

max_denies_per_tool={"shell": 3} budgets one noisy tool without limiting others. max_denies_same_key=2 catches a model retrying the identical path or command.

Sub-agents

When a specialist is invoked via as_tool():

Child settingResult
None (unset)Inherits the parent’s rules and workspace_root for that field
A non-empty listChild rules apply; no inheritance
[] (empty list)Inheritance is disabled and nothing is enforced — not the same as None

Child deny_policy wins when set; otherwise the parent policy is used. Spawn workers copy the template agent’s configured permission lists.

Leaving the field unset is how you make a specialist obey the manager's policy:

python
from elsai.permissions import deny_secrets, read_only

specialist = Agent(
    tools=[file_read],
    config=AgentConfig(
        name="file_specialist",
        description="Reads files requested by the manager.",
        # filesystem_permissions unset → inherits deny_secrets() at call time
    ),
)

manager = Agent(
    tools=[specialist.as_tool(name="ask_file_specialist")],
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[*deny_secrets()],
    ),
)

manager("Ask the file specialist to read .env")   # denied inside the child

Setting rules on the child replaces the parent's list rather than adding to it, which can widen access:

python
specialist = Agent(
    tools=[file_read],
    config=AgentConfig(
        workspace_root="/home/user/project",
        filesystem_permissions=[*read_only()],   # replaces deny_secrets()
    ),
)

manager("Ask the file specialist to read .env")   # now allowed — read_only permits it

[] is the trap: it looks like "nothing to declare" but reads as an explicit empty rule list, so the child enforces nothing at all. Use None when you mean inherit.

Audit logs

Every evaluated filesystem or execution rule writes one INFO JSON line before the tool runs. Use them to see what the agent tried and what policy did — they are not sent to the model.

LoggerEventWhat is recorded
elsai.filesystem.auditfilesystem.permissionDecision, tool, paths, matched_rule
elsai.execution.auditexecution.permissionDecision, tool, command hash (never the raw command), matched_rule

matched_rule is the same identifier as the rule name field: name, else reason, else a generated label such as deny:**/.env. Grep that string, not a name you never set.

Allow and deny each log once. An interrupt logs interrupt; a human reject logs a second event as deny with reviewer_denied: true. Deny records also include deny_action.

python
import logging

logging.getLogger("elsai.filesystem.audit").setLevel(logging.INFO)
logging.getLogger("elsai.execution.audit").setLevel(logging.INFO)

Common mistakes

MistakeWhat happens
Broad allow before a narrow denyThe deny never fires — first match wins
Deny and interrupt in the same multi-path / multi-command calldeny wins; the interrupt is never raised
require_workspace_cwd=True plus patterns on one ruleCwd-fail denies without checking patterns; cwd-ok skips the rule, so the patterns never run
Writing a rule against the literal *.env tool inputInput globs expand to real files first; match **/.env instead
Unanchored exec pattern like rmre.search matches substrings such as npm run format
Expecting deny_destructive() to cover python_replIt covers shell and execute only — add host_shell_disabled() or a custom rule
Setting [] on a sub-agent to "inherit"[] disables enforcement; use None to inherit
Relying on filesystem rules inside a sandboxSandbox file tools are ungated — gate execute and use attach mode
Using on_deny="guide" with interrupt rules but no guidanceRejects fall back to default_guidance or an auto template
Grepping audit logs for a name you never setmatched_rule is namereason → generated label

Copyright © 2026 elsai foundry.