Appearance
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
| Feature | What it is | Typical question |
|---|---|---|
| Permissions | First-match rules on paths and commands | May this tool call run? |
| Sandbox | Separate workspace and backend for plugin tools | Where do files and shell run? |
| Interrupt | Pause / resume API for human review | Did a human approve this pause? |
Rules to remember:
- A path or command with no matching rule is allowed — the default is permissive.
- Rules are evaluated in list order, so put deny rules before broad allow rules.
- Multi-path and multi-command calls are all-or-nothing:
deny>interrupt>allow. - Sandbox
read_file/write_file/list_dirare not gated. Sandboxexecuteis. - Permissions are a guardrail, not isolation — pair them with a Sandbox for containment.
What is gated
| Rule type | Tools evaluated | Matching |
|---|---|---|
| Filesystem | Host file_read, file_write, editor | Glob path patterns per read / write operation |
| Execution | Host shell, python_repl, and sandbox execute | Regex 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
- On
BeforeToolCallEvent, the plugin maps the call to an operation and paths, or to a tool key and commands. - Each path or command is matched against the rule list; the first matching rule decides that item.
- 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. - Deny (and interrupt reject) cancel the tool. An optional
DenyPolicythen 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.",
)| Field | Description |
|---|---|
operations | "read" and/or "write" (must be non-empty) |
paths | Glob patterns (must be non-empty). **, *, and ? are supported |
mode | "allow", "deny", or "interrupt" |
name | Identifier 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 |
reason | The 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)) |
guidance | An 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
| Tool | Operation | Paths |
|---|---|---|
file_read | read | path (comma-separated) and comparison_path when mode="diff" |
file_write | write | path |
editor | read for view, find_line; write for create, str_replace, pattern_replace, insert, undo_edit | path |
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")| Preset | Behaviour |
|---|---|
workspace_only | Allow read+write under /workspace/**; deny everything else |
read_only | Allow read under /workspace/**; deny all writes and reads outside workspace |
deny_secrets | Deny 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 workspaceSwapping 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.",
)| Field | Description |
|---|---|
tools | "shell", "python_repl", and/or "execute" |
patterns | Regexes matched with re.search() against the command or REPL code. Required unless require_workspace_cwd=True |
mode | "allow", "deny", or "interrupt" |
require_workspace_cwd | If 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 |
name | Identifier 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 |
reason | The 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)) |
guidance | An 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 (modeapplies).patternson 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=Trueare 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-workspaceCommand mapping
| Tool | What is matched | Working directory |
|---|---|---|
shell | command — string, list of strings, or JSON list string | work_dir |
python_repl | code | — |
execute | command | cwd |
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")| Preset | Behaviour |
|---|---|
deny_destructive | Deny rm, mv, mkfs, dd, and shell redirects on shell and execute (not python_repl) |
interrupt_all_shell | Interrupt every host shell call for human approval |
interrupt_all_execute | Interrupt every sandbox execute call |
allow_safe_readonly_shell | Allow ls, cat, head, tail, pwd, echo, read-only git, and pytest; deny other shell |
require_workspace_cwd | Deny shell / execute when cwd is outside workspace_root |
host_shell_disabled | Deny 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_cwdallow_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 → deniedNote 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
| Mode | Effect |
|---|---|
allow | Tool runs (unless another path or command in the same call is deny/interrupt) |
deny | Tool is cancelled before execution |
interrupt | Agent 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 reviewer2. 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:
| Constant | Value | Raised 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.
| Field | Default | Description |
|---|---|---|
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_invocation | None | Cap total denials in one agent(...) call |
max_denies_per_tool | None | Cap denials per tool name, e.g. {"shell": 3} |
max_denies_same_key | None | Cap repeats of the same path set or command |
default_guidance | None | Steer text used when a rule has no guidance |
budget_guidance | None | Steer 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 completionguide — 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 setting | Result |
|---|---|
None (unset) | Inherits the parent’s rules and workspace_root for that field |
| A non-empty list | Child 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 childSetting 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.
| Logger | Event | What is recorded |
|---|---|---|
elsai.filesystem.audit | filesystem.permission | Decision, tool, paths, matched_rule |
elsai.execution.audit | execution.permission | Decision, 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
| Mistake | What happens |
|---|---|
Broad allow before a narrow deny | The deny never fires — first match wins |
| Deny and interrupt in the same multi-path / multi-command call | deny wins; the interrupt is never raised |
require_workspace_cwd=True plus patterns on one rule | Cwd-fail denies without checking patterns; cwd-ok skips the rule, so the patterns never run |
Writing a rule against the literal *.env tool input | Input globs expand to real files first; match **/.env instead |
Unanchored exec pattern like rm | re.search matches substrings such as npm run format |
Expecting deny_destructive() to cover python_repl | It 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 sandbox | Sandbox file tools are ungated — gate execute and use attach mode |
Using on_deny="guide" with interrupt rules but no guidance | Rejects fall back to default_guidance or an auto template |
Grepping audit logs for a name you never set | matched_rule is name → reason → generated label |