Home/Guides/Implement Permission Controls

Implement Permission Controls

How to add a permission layer to your agent. Covers the cascade model, permission modes, bypass-immune checks, and session grants.

Your agent runs tools. Right now, it runs every tool the model asks for, no questions asked. That works during development. In production, it is a liability. An agent without permission controls can delete files, send emails, execute arbitrary shell commands, and modify database records, all because the model decided to. The model is making tool selection decisions based on what it thinks the user wants, and it is often right. But "often right" is not "always right," and the cost of a wrong tool call that deletes a production database is not recoverable.

Permission controls are the layer between "the model wants to call this tool" and "the tool actually runs." This guide walks through adding that layer to an existing agent: a permission cascade that evaluates multiple policy sources, permission modes that provide global overrides, bypass-immune checks that hold even when the user says "allow everything," and session grants that let users approve tools for the duration of a session.

The Problem

Without permission controls, your agent's dispatch function looks like this:

async function dispatch_tool(name: str, args: dict) -> ToolResult:
  tool = registry.get(name)
  return await tool.implementation(args)

Every tool call the model generates goes straight to execution. The model asks for delete_file, and the file is deleted. The model asks for send_email, and the email is sent. There is no check, no confirmation, no audit trail.

The consequences scale with the tool set:

  • An agent with write_file can overwrite configuration files, breaking the deployment.
  • An agent with execute_command can run rm -rf / if the model generates that input.
  • An agent with send_api_request can make unauthorized calls to external services.

The fix is not to remove dangerous tools. The agent needs them to do its job. The fix is to control when and how they run.

The Permission Cascade

A permission check needs to evaluate multiple sources. The project might have a policy that denies shell execution. The user's personal settings might allow file writes. A CLI flag might grant extra permissions for this session. These sources can disagree, and when they do, you need a deterministic resolution.

The cascade model resolves this: evaluate sources in priority order, and the first source with an opinion wins. If no source has an opinion, the default is deny. Fail-closed.

The following implements a permission cascade with three sources (expand to six for a full production system):

SOURCES = ["project_policy", "user_settings", "session_grants"]

function evaluate_permission(tool_name: str, context: PermissionContext) -> Decision:
  for source in SOURCES:
    rules = context.get_rules(source)

    # Check deny rules first: most restrictive wins within a source
    for rule in rules.deny:
      if rule.matches(tool_name):
        return Decision(action="DENY", source=source, reason=rule.reason)

    # Check allow rules
    for rule in rules.allow:
      if rule.matches(tool_name):
        return Decision(action="ALLOW", source=source, reason=rule.reason)

  # No source had an opinion, fail-closed
  return Decision(action="DENY", source="default", reason="no matching rule")

Three properties of this cascade are worth naming:

First-match wins. Each source returns ALLOW, DENY, or no opinion. The cascade stops at the first non-abstaining source. Given any tool call and context, you can trace exactly which source made the decision. No ambiguity.

Deny beats allow within a source. Deny rules are checked before allow rules. Within any single source, the most restrictive opinion wins. A project policy that denies shell execution cannot be overridden by an allow rule in the same policy file.

Every decision is logged. The audit trail carries the matched source and the reason. When debugging an unexpected denial, you can see exactly which rule fired and from which configuration level.

Tip: Default to deny for any tool you have not explicitly classified. Fail-closed is the only safe default. An unclassified tool that runs by default is a security hole waiting to be discovered.

Permission Modes

The cascade handles per-tool decisions. Permission modes provide a global override that changes the default behavior for entire categories of tools. Modes are useful when you want the agent to operate in a specific posture without configuring every tool individually.

The five modes:

type PermissionMode = "default" | "plan" | "accept_edits" | "bypass" | "silent_deny"

function apply_mode(mode: PermissionMode, tool: ToolConfig) -> Decision or None:
  if mode == "plan":
    # Read-only mode: all write tools auto-denied
    if tool.has_write_effect:
      return DENY("plan mode: write tools blocked")
    return None   # reads fall through to cascade

  if mode == "accept_edits":
    # File edits auto-approved, everything else falls through
    if tool.is_file_edit:
      return ALLOW
    return None

  if mode == "bypass":
    # Everything auto-approved (requires explicit opt-in)
    return ALLOW

  if mode == "silent_deny":
    # Everything not explicitly allowed is silently denied
    return DENY("silent deny mode")

  # Default mode: fall through to cascade for all tools
  return None

Plan mode is the most common in practice. When the agent is planning (deciding what to do before doing it), you want it to be able to read files and search but not write, execute, or send. Plan mode enforces this without touching the cascade configuration.

Bypass mode auto-approves everything. Use it only in controlled environments (CI pipelines, automated testing) where the tool set is already constrained. Never expose bypass mode as a user-facing option without understanding the implications.

Modes are evaluated before the cascade. If the mode has an opinion, the cascade is skipped entirely for that tool call.

Bypass-Immune Checks

Some safety rules must hold regardless of the permission mode or cascade configuration. If the agent is scoped to a specific directory, it must not write files outside that directory, even if bypass mode is active. If the agent has a budget limit, it must not exceed it, even if every tool is allowed.

Bypass-immune checks run before the cascade and before mode evaluation. They cannot be overridden by any policy source:

function check_bypass_immune(tool_name: str, args: dict, context: PermissionContext) -> Decision or None:
  # Scope check: agent must not act outside its designated directory
  if is_file_operation(tool_name):
    target_path = extract_path(args)
    if not is_within_scope(target_path, context.allowed_directories):
      return DENY("scope violation: path outside allowed directories")

  # Budget check: agent must not exceed cost limit
  if context.budget_remaining <= 0:
    return DENY("budget exceeded")

  # Network check: agent must not access blocked domains
  if is_network_operation(tool_name):
    target = extract_host(args)
    if target in context.blocked_hosts:
      return DENY(f"blocked host: {target}")

  return None   # no bypass-immune violation, proceed to mode/cascade

The critical design property: bypass-immune checks are not policy. They are invariants. If they were inside the cascade, a sufficiently privileged policy source could override them. Running them before the cascade makes them unconditional.

Wire Into Dispatch

The permission layer integrates into the dispatch function between argument parsing and execution:

async function dispatch_tool(name: str, args: dict, context: ToolContext) -> ToolResult:
  tool = registry.get(name)
  if tool is None:
    return error_result(f"Unknown tool: {name}")

  # Parse arguments
  parsed = tool.schema.parse(args)
  if not parsed.success:
    return error_result(f"Invalid arguments: {parsed.error}")

  # Permission check: bypass-immune -> mode -> cascade
  immune_check = check_bypass_immune(name, parsed.data, context.permissions)
  if immune_check is not None:
    log_permission(name, immune_check)
    return error_result(f"Permission denied: {immune_check.reason}")

  mode_check = apply_mode(context.permissions.mode, tool.config)
  if mode_check is not None:
    if mode_check.action == "DENY":
      log_permission(name, mode_check)
      return error_result(f"Permission denied: {mode_check.reason}")
  else:
    cascade_check = evaluate_permission(name, context.permissions)
    if cascade_check.action == "DENY":
      log_permission(name, cascade_check)
      return error_result(f"Permission denied: {cascade_check.reason}")

  # Permission granted, execute
  return await tool.implementation(parsed.data, context)

The order is fixed: bypass-immune checks first, then mode, then cascade. Each layer can short-circuit. If bypass-immune denies, mode and cascade never evaluate. This ordering ensures that the strongest constraints are always checked first.

Session Grants

Users do not want to approve every tool call individually. After approving write_file once, they want it to run freely for the rest of the session. Session grants provide this UX:

class SessionGrantStore:
  grants: dict = {}   # tool_name -> GrantDecision

  function grant(self, tool_name: str, scope: str = "session"):
    self.grants[tool_name] = GrantDecision(
      action="ALLOW",
      scope=scope,
      granted_at=now()
    )

  function check(self, tool_name: str) -> Decision or None:
    grant = self.grants.get(tool_name)
    if grant is not None:
      return Decision(action="ALLOW", source="session", reason="session grant")
    return None

Session grants are the lowest priority in the cascade. They can be overridden by project policy, user settings, or any higher source. They expire when the session ends. They cannot override bypass-immune checks.

The typical UX flow: the model requests a tool call, the cascade returns ASK (no allow or deny rule matched), the user is prompted, the user approves and checks "allow for this session," and a session grant is recorded. Subsequent calls to the same tool skip the prompt.

  • Safety and Permissions. The full permission architecture: six cascade sources, five permission modes, denial tracking with escalation thresholds, shadow rule detection, and multi-agent permission forwarding.
  • Tool System. How tools carry permission metadata alongside their schema, and how behavioral flags like is_destructive feed into the permission cascade.
  • Multi-Agent Coordination. How permissions are forwarded when a coordinator spawns workers, and why workers inherit a restricted permission scope.