5.4 Permission Engine
What you'll learn
- The two-layer model: rule-engine first,
confirm_toolonly for ASKs- The 4 preset modes (
READ_ONLY/WORKSPACE_WRITE/FULL_ACCESS/PLAN)- The domain rule semantics for
web_fetch(allowlist, blocklist, exact vs. suffix match)
The PermissionEngine is Agentao's first line of defense. It makes a rule-based decision before the tool actually runs, automatically handling "clearly safe" and "clearly dangerous" requests and leaving only edge cases to confirm_tool() (Part 4.5).
Two-layer defense model
┌────────────────────────┐
LLM → tool ──► │ 1. PermissionEngine │──┬─ ALLOW → execute
│ (JSON rules + preset)│ ├─ DENY → refuse
└────────────────────────┘ └─ ASK ──┐
▼
┌────────────────────┐
│ 2. confirm_tool() │
│ (user UI prompt) │
└────────────────────┘Layer 1 is zero-latency (rule match); layer 2 is seconds (user clicks). Good rules mean 90% of tool calls don't disturb the user at all.
PermissionDecision
class PermissionDecision(Enum):
ALLOW = "allow" # execute
DENY = "deny" # refuse (agent sees "cancelled" synthetic result)
ASK = "ask" # route to confirm_toolIf no rule matches (decide() returns None), the agent falls back to the tool's own requires_confirmation.
PermissionMode: four presets
| Mode | Writes | Shell | Web | Best for |
|---|---|---|---|---|
READ_ONLY | Deny (no rule can allow) | Deny (no rule can allow) | Ask (rules apply) | Read-only exploration, audit |
WORKSPACE_WRITE | Allow | Rule-gated | Allowlist/blocklist/ask | Default for production |
FULL_ACCESS | Allow | Allow | Allow | Dev / fully trusted |
PLAN | Deny (only plan_*) | Deny (only git etc.) | Allowlist | Internal to Plan mode |
Switching modes:
agent = Agentao(..., permission_mode="workspace-write") # builds the engine
agent.set_permission_mode("read-only") # returns PermissionMode.WORKSPACE_WRITEModes are strings — "read-only", "workspace-write", "full-access", "plan", the vocabulary of ActivePermissions.mode. The PermissionMode enum is still accepted. An unknown string raises ValueError. Switchable at runtime — takes effect on the next tool call.
Prefer set_permission_mode over agent.permission_engine.set_mode(...), which is only half the switch. read-only has two of them — the engine's preset and ToolRunner.readonly_mode — and the engine holds no transport, so a bare set_mode also emits neither READONLY_MODE_CHANGED nor PERMISSION_MODE_CHANGED, leaving a replay file with the resulting denials and nothing saying when the posture changed. set_permission_mode moves both switches, emits both events (tagged cause="host", or "cli" / "cli-allow-all" / "cli-plan-implement" / "cli-resume" / "acp" / "run" from those entry points), and returns the mode that was active before — as the PermissionMode enum, not a string (.value gives the string).
Modes need an engine. build_from_environment always builds one, starting in workspace-write unless permission_mode= says otherwise. A bare Agentao(...) with neither permission_mode= nor permission_engine= has none: no rule is evaluated, and set_permission_mode raises ValueError. permission_mode= takes "read-only", "workspace-write" or "full-access"; "plan" is refused, because plan mode is entered through the plan session. The agent starts in that mode silently — a starting state is not a switch, so no event is emitted; switches after construction are recorded as above. When you need rules=, build the engine yourself and pass permission_engine=. The rule is one for callers: an engine you pass and a mode are mutually exclusive (ValueError). Agentao(permission_mode=) builds PermissionEngine(project_root=working_directory, rules=[]) and reads no rule file; build_from_environment(permission_mode=) applies the mode to the engine the factory loads from the permission files — an internal engine, not one you passed — so the user's rules stay.
"Ask" is answered by the transport. Every "Ask" in the table above goes to transport.confirm_tool. The default NullTransport answers yes to all of them except a confirmation the MCP Skills gate raises, so in a headless host "Ask" means "Allow". A host with nobody to ask that should refuse passes SdkTransport(confirm_tool=lambda *_: False).
Headless, an ask therefore runs: a shell command outside the read-only allowlist, web_fetch to an unlisted domain, web_search, a write into .git/, .agentao/ or a credential-shaped path. What still stops a call under NullTransport:
| Stop | Needs an engine? |
|---|---|
| Read-only mode | Yes. It is set through set_permission_mode or permission_mode= |
An engine deny rule, including the workspace-write preset's own | Yes |
The hardline command floor on run_shell_command (rm -rf /, mkfs, …) | No. It stays on without an engine (permissions_hardline/) |
The MCP Skills gate, which NullTransport refuses | No |
web_fetch's URL policy (private and metadata addresses) | No (6.3) |
Rule JSON format
Location:
~/.agentao/permissions.json ← user-level (only file-based source)Project-scope file is ignored
A <cwd>/.agentao/permissions.json is not loaded — the engine logs a warning and ignores it. A checked-in {"tool": "*", "action": "allow"} would defeat the user policy on first match (the engine returns at the first matching rule), so project files cannot grant capabilities. Permissions are a user/host concern, not a cwd concern — same model OS permissions and IDE workspace-trust use. If you need project-aware policy, inject it from the host: see 4.7.6 active_permissions.
Structure:
{
"rules": [
{"tool": "read_file", "action": "allow"},
{"tool": "write_file", "args": {"path": "^/tmp/"}, "action": "allow"},
{"tool": "write_file", "action": "ask"},
{"tool": "run_shell_command", "args": {"command": "rm\\s+-rf"}, "action": "deny"},
{"tool": "*", "action": "ask"}
],
"shell": { "dialect": "powershell" }
}The file has two top-level keys. rules is everything below; shell (new in 0.4.22, Windows only) chooses the interpreter run_shell_command uses and the syntax the command floor scans with — see appendix B.3.2, or docs/reference/configuration.md §4 for the launch and the encoding. An unknown top-level key is a named error, not an ignored one.
Evaluation order (first match wins):
| Mode | Order |
|---|---|
| FULL_ACCESS / PLAN | Preset rules → user JSON |
| Others | User JSON → preset rules |
Rule fields
tool — tool name
{"tool": "write_file"} // exact
{"tool": "mcp_github_*"} // wildcard prefix
{"tool": "*"} // allargs — regex on arguments
Keys are arg names, values are Python regexes. All keys must match for the rule to fire:
{
"tool": "write_file",
"args": {
"path": "^/tmp/safe-dir/" // path must start with /tmp/safe-dir/
},
"action": "allow"
}Multiple args must all match:
{
"tool": "run_shell_command",
"args": {
"command": "^docker ", // starts with docker
"cwd": "^/var/app/" // and cwd under /var/app/
},
"action": "allow"
}domain — URL host (for web_fetch etc.)
{
"tool": "web_fetch",
"domain": {
"allowlist": [".github.com", ".docs.python.org", "r.jina.ai"]
},
"action": "allow"
}Match semantics:
| Pattern | Meaning |
|---|---|
.github.com | Suffix match: matches github.com and api.github.com; does NOT match notgithub.com |
github.com | Exact: only matches github.com itself |
A single domain rule can list both allowlist and blocklist — a hit on either counts (so to split allow/deny, write two rules, not one).
The built-in WORKSPACE_WRITE preset already ships a reasonable set (see below).
How the host is extracted
The engine reads the URL from the url argument. Set domain.url_arg to read another argument. It takes the host from urlparse(url).hostname (_extract_domain):
- Matching ignores case. The port is dropped, so
docs.python.org:8443matches.docs.python.org. - Userinfo is dropped.
https://github.com@evil.com/has the hostevil.com, notgithub.com. - A URL without a scheme is read as
https://. - A URL with no host never matches a domain rule. The engine then tries the next rule.
- An IP address is an exact pattern (no leading dot).
1.127.0.0.1does not match127.0.0.1.
The rule sees only the URL that the model passed. Redirect hops are checked later, by the URL policy at request time (6.3 Layer 2).
With the default WORKSPACE_WRITE rules:
| Call | Decision | Why |
|---|---|---|
web_fetch https://docs.python.org/3/library/os.html | ALLOW | Suffix match on .docs.python.org |
web_fetch http://169.254.169.254/latest/meta-data | DENY | Exact match in the blocklist |
web_fetch https://example.com/api | ASK | No domain rule matches. The catch-all web_fetch ask rule applies |
action
{"action": "allow"}
{"action": "deny"}
{"action": "ask"}action is required, like tool. A rule without it fails validation (PermissionRuleError, or PermissionConfigError for a file). The engine does not fill in a default.
Preset cheat sheet
The WORKSPACE_WRITE preset (source agentao/permissions.py::_PRESET_RULES):
[
{"tool": "write_file", "action": "allow"},
{"tool": "replace", "action": "allow"},
// Read-only shell allowlist (git status/log/diff, ls, cat, echo, pwd, which, head, tail, …)
{"tool": "run_shell_command", "args": {"command": "^(git (status|log|...)|ls\\b|cat\\b|...)"}, "action": "allow"},
// Dangerous-command denylist (rm -rf, sudo, mkfs, dd if=)
{"tool": "run_shell_command", "args": {"command": "rm\\s+-rf|sudo\\s|mkfs|dd\\s+if="}, "action": "deny"},
{"tool": "run_shell_command", "action": "ask"},
{"tool": "web_fetch", "domain": {"allowlist": [".github.com", ".docs.python.org", ".wikipedia.org", "r.jina.ai", ".pypi.org", ".readthedocs.io"]}, "action": "allow"},
{"tool": "web_fetch", "domain": {"blocklist": ["localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254", ".internal", ".local", "::1"]}, "action": "deny"},
{"tool": "web_fetch", "action": "ask"},
{"tool": "web_search", "action": "ask"}
]SSRF protection: the blocklist contains common internal / metadata addresses to prevent the agent from being prompt-injected into fetching internal services. Extend rather than shrink this list in production.
Programmatic customization
Pass your own engine to the agent:
from agentao import Agentao
from agentao.permissions import PermissionEngine, PermissionMode
engine = PermissionEngine(project_root=Path("/data/tenant-a"))
engine.set_mode(PermissionMode.WORKSPACE_WRITE)
# Add rules dynamically (e.g. based on tenant plan)
engine.rules.insert(0, {
"tool": "mcp_slack_*",
"action": "ask" if tenant.free_tier else "allow",
})
agent = Agentao(
working_directory=Path("/data/tenant-a"),
permission_engine=engine,
)Reading the active policy from the host
Hosts that need to render the current policy in their own UI (or pin it into an audit log) call agent.active_permissions() — the host-stable getter on the harness contract:
snap = agent.active_permissions()
# snap.mode -> "workspace-write"
# snap.rules -> [...] # list[dict], JSON-safe
# snap.loaded_sources -> ["preset:workspace-write",
# "user:/Users/me/.agentao/permissions.json"]loaded_sources carries stable string labels: preset:<mode>, user:<path>, injected:<name>. (The project:<path> label is no longer emitted — see the warning above.) The MVP intentionally does not expose per-rule provenance — hosts that need rule-level provenance combine loaded_sources with their own injected policy metadata.
If the host layers extra policy on top of the engine (a runtime-computed allowlist, a tenant-scoped overlay, etc.), it labels its own provenance via add_loaded_source(...):
engine.rules.insert(0, {"tool": "mcp_slack_*", "action": "ask"})
engine.add_loaded_source("injected:tenant-overlay")
snap = agent.active_permissions()
# snap.loaded_sources includes "injected:tenant-overlay"The snapshot is cached; the cache is invalidated on set_mode() and on add_loaded_source(...) with a new label (duplicate labels are coalesced and do not force a rebuild). Direct mutation of engine.rules does not invalidate the cache — if you mutate rules in place, follow up with set_mode(engine.active_mode) (a no-op-mode set still clears the cache) or label the change via add_loaded_source("injected:<unique-name>").
The same surface drives PermissionDecisionEvent.loaded_sources on the public event stream. For the full how-to (including audit-pipeline patterns), see 4.7 Embedded Harness Contract. For the dense field reference, see Appendix A.10.
Common templates
Template A · Strict production (customer product)
{
"rules": [
{"tool": "read_file", "action": "allow"},
{"tool": "glob", "action": "allow"},
{"tool": "grep", "action": "allow"},
{"tool": "write_file", "args": {"path": "^/workspace/"}, "action": "allow"},
{"tool": "write_file", "action": "deny"},
{"tool": "run_shell_command", "action": "deny"},
{"tool": "web_fetch", "domain": {"allowlist": [".your-company.com"]}, "action": "allow"},
{"tool": "web_fetch", "action": "deny"},
{"tool": "*", "action": "ask"}
]
}Template B · Dev sandbox
{
"rules": [
{"tool": "run_shell_command", "args": {"command": "^docker |^npm |^python |^node "}, "action": "allow"},
{"tool": "run_shell_command", "args": {"command": "rm\\s+-rf /|sudo|mkfs"}, "action": "deny"},
{"tool": "run_shell_command", "action": "ask"}
]
}Template C · CI / unattended
CI has no one to click "allow", so rules must be explicitly allow/deny — no "ask":
{
"rules": [
{"tool": "write_file", "args": {"path": "^/tmp/ci/"}, "action": "allow"},
{"tool": "read_file", "action": "allow"},
{"tool": "glob", "action": "allow"},
{"tool": "grep", "action": "allow"},
{"tool": "*", "action": "deny"}
]
}Coordination with confirm_tool
When decide() returns ASK, the agent calls transport.confirm_tool(...). Your UI only handles edge cases — no popup per tool call.
Verify rules work:
from agentao.permissions import PermissionDecision
for tool, args in [
("write_file", {"path": "/tmp/safe.txt", "content": "..."}),
("write_file", {"path": "/etc/passwd", "content": "..."}),
("run_shell_command", {"command": "rm -rf /"}),
("web_fetch", {"url": "http://127.0.0.1:8080"}),
]:
dec = engine.decide(tool, args)
print(f"{tool}({args}) → {dec}")Turn this into a unit test — guarantee the rules you expect to hit are hit before deploying.
⚠️ Common pitfalls
Don't ship without these
- ❌ Wrong rule order — first match wins, deny rules placed after allow rules never fire
- ❌ No catch-all — undefined tool calls fall through to defaults you didn't audit
- ❌ Unescaped regex —
.matches everything, not just a literal dot - ❌
"github.com"thinking it's a suffix match — without the leading dot it's exact match
Each pitfall below has the full fix.
❌ Wrong rule order
[
{"tool": "write_file", "action": "ask"},
{"tool": "write_file", "args": {"path": "^/tmp/"}, "action": "allow"}
]The first rule matches every write_file, so the second is never evaluated. Put specifics first:
[
{"tool": "write_file", "args": {"path": "^/tmp/"}, "action": "allow"},
{"tool": "write_file", "action": "ask"}
]❌ No catch-all
Without {"tool": "*", ...}, unmatched tools fall back to each tool's requires_confirmation — the behavior may surprise you. Prefer an explicit catch-all in production.
❌ Unescaped regex
In JSON, backslashes double: "rm\\s+-rf".
❌ "github.com" thinking it's a suffix match
Missing the leading dot means exact. For subdomain matches write ".github.com".
TL;DR
- Two layers: rule engine (zero-latency, ALLOW / DENY / ASK) →
confirm_tool(seconds, only for ASK). - Pick a preset to start:
WORKSPACE_WRITEis the production default.READ_ONLYfor audit.FULL_ACCESSonly for trusted local dev.PLANfor read-only pre-commit review. - Domain rules:
".github.com"(leading dot) = suffix;"github.com"(no dot) = exact. The default preset'sweb_fetchblocklist denieslocalhost,127.0.0.1,0.0.0.0,::1,169.254.169.254,.internaland.local. It holds no RFC 1918 ranges: private addresses are refused separately, when the request is made, by the URL policy (6.3 Layer 2). - Layer rules over presets — write rules for your tools and MCP servers; defaults rarely cover them.
→ Next: 5.5 Memory System