May 19, 2026 — Matteo Merola

Permission Engineering for Autonomous Agents

How to let agents act on your behalf without giving them the keys to everything.

An agent that can only read is a chatbot. An agent that can write without guardrails is a liability. The interesting engineering sits between those two extremes: giving an agent enough autonomy to be useful while keeping a human in the loop for anything destructive.

Most permission systems were designed for humans clicking buttons. OAuth scopes, RBAC roles, POSIX file permissions — they assume the caller understands consequences. Agents don't. An agent will happily rm -rf / if you ask it to “clean up disk space” and the permission model doesn't intervene. The challenge is building a permission layer that's granular enough to be safe, fast enough to not slow down autonomous operation, and simple enough that you can reason about what the agent can actually do.

Three-tier glob rules

The foundation is a simple classification: every tool invocation falls into one of three buckets — ALWAYS, ASK, or NEVER. Rules are defined as glob patterns matched against the tool name and its arguments.

config.yml — permission rules
permissions:
  - pattern: "read_email:*"
    action: ALWAYS
  - pattern: "send_email:*"
    action: ASK
  - pattern: "run_command:rm *"
    action: NEVER
  - pattern: "run_command:git push *"
    action: ASK
  - pattern: "run_command:git status"
    action: ALWAYS
  - pattern: "write_file:*/workspace/*"
    action: ALWAYS
  - pattern: "write_file:*"
    action: NEVER

The matching logic is intentionally simple — Python's fnmatch against a tool_name:arg_string key. Rules are evaluated top to bottom; first match wins. This means you put specific allows above general denies, just like firewall rules.

permissions.py
from fnmatch import fnmatch
from enum import Enum

class Action(Enum):
    ALWAYS = "always"
    ASK    = "ask"
    NEVER  = "never"

def check_permission(rules, tool_name, args_str) -> Action:
    key = f"{tool_name}:{args_str}"
    for rule in rules:
        if fnmatch(key, rule["pattern"]):
            return Action(rule["action"].lower())
    return Action.ASK  # default: ask the human

The default is ASK, not ALWAYS. This is the most important design decision in the whole system. Any tool invocation that doesn't match an explicit rule gets routed to the human for approval. You opt in to autonomy rather than opting out of danger.

The approval flow

When a tool call resolves to ASK, the agent's turn pauses and a notification goes out to the admin channel. In humux, this is a Telegram message with inline buttons: Approve, Deny, and Approve & Remember (which adds a new ALWAYS rule so the same action won't ask again).

The approval message includes exactly what will execute — the tool name, the full arguments, and which agent is requesting it. No summarization, no natural-language paraphrase. You see the raw action because a paraphrase can hide the difference between git push origin main and git push origin main --force.

Timeouts matter. If no response arrives within a configurable window (default: 5 minutes), the action is denied and the agent is told the approval timed out. This prevents zombie turns from piling up while you're asleep. The agent can decide to retry, skip, or ask the user what to do — but it won't sit blocked forever.

Secrets that never touch the model

The hardest credential problem in agent systems isn't encryption — it's keeping secrets out of the prompt. If an agent has an API key in its context window, it can leak it in a response, log it, or include it in a tool call that sends data externally. The principle is straightforward: credentials are resolved at the execution boundary, never in the model context.

humux uses a two-tier encrypted vault. Infrastructure secrets (database URLs, API keys for the orchestration layer itself) are sealed with a machine-derived key — available at boot without human interaction. Agent-specific secrets (a user's email password, a calendar OAuth token) are sealed with an admin password using Fernet symmetric encryption and only decrypted when a tool actually needs them.

vault.py — runtime resolution
import os
from cryptography.fernet import Fernet

def resolve_secrets(agent_slug, tool_env: dict) -> dict:
    """Inject secrets into env vars for CLI tool execution.
    The model never sees these values."""
    env = os.environ.copy()
    vault = load_agent_vault(agent_slug)
    for key, encrypted_val in vault.items():
        if key in tool_env:
            env[key] = _fernet.decrypt(encrypted_val).decode()
    return env

# Tool execution uses the resolved env
result = subprocess.run(
    cmd, env=resolve_secrets(agent.slug, tool.env_keys),
    capture_output=True, timeout=30
)

The model asks to “send an email to X”, the tool executor resolves the SMTP password from the vault into an environment variable, runs the email CLI, and returns only the result (sent/failed) back to the model. At no point does the password exist in the conversation history.

Workspace confinement

Permission rules handle which tools run. Workspace confinement handles where they can operate. Every agent gets a workspace directory, and all file operations are confined to it via realpath checks.

confinement.py
from pathlib import Path

def confine(path: str, workspace: Path) -> Path:
    resolved = Path(path).resolve()
    if not resolved.is_relative_to(workspace):
        raise PermissionError(
            f"path {resolved} escapes workspace {workspace}"
        )
    return resolved

This catches symlink attacks and ../../../etc/passwd traversals. The resolve() call follows symlinks before checking containment, so you can't bypass it by creating a symlink inside the workspace that points outside.

The bash executor applies a similar principle: a whitelist of allowed commands, and each command's arguments are checked against the workspace boundary. Commands like curl, wget, or anything that can exfiltrate data are either blocked or gated behind ASK rules.

Why not OAuth scopes or sandboxing?

OAuth scopes are designed for third-party app authorization. They're too coarse: a scope like mail.send either lets the agent send any email to anyone or blocks all sends. There's no way to express “send email, but ask me first if the recipient is outside my organization.” Glob patterns over tool+args let you express exactly that.

Full sandboxing (containers, VMs, seccomp profiles) solves a different problem. It protects the host from the agent, but it also prevents the agent from doing useful work. An agent that can't access the filesystem, network, or system tools is an agent that can't send emails, manage calendars, or run CLI tools. The permission layer needs to operate within the agent's execution context, not around it.

Capability-based security is the closest theoretical fit. Each tool invocation is a capability, and the permission system decides whether to grant it. The difference from a textbook capability system is pragmatic: capabilities are matched by pattern, not by unforgeable token, because the agent is the only caller and we control the execution environment. This trades some theoretical purity for operational simplicity — you can read the config file and understand exactly what the agent can do.

The compound effect

None of these mechanisms is novel in isolation. Glob matching is ancient. Fernet encryption is a solved problem. Realpath confinement is a standard defense. The value is in the composition: permissions decide if an action runs, the vault keeps secrets out of the model context, workspace confinement limits where file operations land, and the approval flow keeps a human in the loop for anything uncertain.

The system is deliberately boring. Every component is something you could implement in an afternoon. That's the point — the permission model for an autonomous agent shouldn't be the thing that keeps you up at night. It should be a predictable stack of simple rules that you can audit by reading a YAML file.

humux implements permission engineering for autonomous agents out of the box. Try it →