Permission Modes & Safety Controls

Control exactly what your AI agent can read, write, and execute — with enterprise-grade precision.

Intermediate 15 min read
1

Why Permissions Matter

AI agents are powerful precisely because they can take real actions: read source code, write files, run shell commands, and call external services. That power requires guardrails.

Without controls, an agent could accidentally delete files, run a destructive rm -rf, overwrite your .env with secrets, or exfiltrate data through a network call. Worse, a prompt injection hidden in a source file could hijack the agent's actions entirely.

Real risks without permission controls

Agents with unconstrained bash access can run curl to exfiltrate code, rm -rf to destroy files, or git push to leak secrets. Permission modes are not optional in production.

Harness solves this with a four-mode permission system backed by a 4-tier precedence engine — the most sophisticated permission architecture available in any AI coding agent.

What competitors can't do

Claude Code has basic allow/deny. No other AI coding agent has a 4-tier precedence engine, policy engine integration, or custom approval callbacks. Harness gives you surgical control over every tool call the agent makes.

2

The Four Permission Modes

Select the mode that matches your trust level and use case.

default
Asks for approval before every file write, bash command, and potentially destructive action.
Reads: Ask Writes: Ask Bash: Ask
accept_edits
Auto-approves all file reads and writes. Still asks before running bash commands.
Reads: Auto Writes: Auto Bash: Ask
plan
Read-only mode. The agent can analyse files but cannot modify anything or run commands.
Reads: Auto Writes: Block Bash: Block
bypass
Auto-approves everything. Use only in trusted CI/CD environments with pre-defined rules.
Reads: Auto Writes: Auto Bash: Auto
Mode File Reads File Writes Bash Commands Best For
default Ask Ask Ask Untrusted tasks
accept_edits Auto Auto Ask Coding tasks
plan Auto Block Block Code review, analysis
bypass Auto Auto Auto CI/CD, trusted scripts
3

Same Task, Four Modes

Pass --permission on the CLI to control mode. Watch how the agent's behaviour changes for the identical task.

bash
# Default — asks permission for everything
harness --permission default "Refactor db.py to use connection pooling"

# Accept edits — auto-approves file changes, asks for bash
harness --permission accept_edits "Refactor db.py to use connection pooling"

# Plan — read-only, won't modify anything
harness --permission plan "Refactor db.py to use connection pooling"

# Bypass — auto-approves everything (use in CI/CD)
harness --permission bypass "Refactor db.py to use connection pooling"
Mode What happens
default Agent pauses before each file write and bash command, showing you a diff and asking y/n
accept_edits File edits apply automatically; if refactoring requires running tests, the agent asks first
plan Agent reads and analyses db.py, outputs a detailed refactoring plan — but touches nothing
bypass Fully autonomous: reads, writes, runs tests, commits — zero interruptions
Try it

Start with plan mode to get a safe analysis, review the plan, then re-run with accept_edits to apply the changes.

4

Permission Rules (Allow/Deny)

For fine-grained control, use PermissionConfig to define explicit allow and deny rules that target individual tools and argument patterns.

python
from harness import run, PermissionMode
from harness.permissions.rules import PermissionConfig, PermissionRule, PermissionDecision

# Create custom rules
rules = PermissionConfig()

# Allow all file operations
rules.add_allow("Read")
rules.add_allow("Write")
rules.add_allow("Edit")
rules.add_allow("Glob")
rules.add_allow("Grep")

# Deny dangerous bash commands
rules.add_deny("Bash", args_pattern={"command": "rm -rf*"})
rules.add_deny("Bash", args_pattern={"command": "*sudo*"})
rules.add_deny("Bash", args_pattern={"command": "*curl*"})

async for msg in run(
    "Refactor the codebase",
    permission_mode="default",
    permission_rules=rules,
):
    ...
args_pattern uses fnmatch glob patterns

The args_pattern dict matches argument names to fnmatch glob patterns via fnmatch.fnmatch(). Use * to match any sequence of characters, ? to match a single character, and [seq] to match characters in a sequence.

Tool nameWhat it controls
ReadReading file contents
WriteWriting new files
EditEditing existing files (targeted replacements)
GlobFile pattern matching / directory listing
GrepSearching file contents with regex
BashRunning shell commands
WebFetchFetching URLs over HTTP
TaskSpawning sub-agent tasks
5

Build a Safe Refactoring Agent

Let's build a real production scenario: refactor database code while ensuring the agent can never touch shell commands or sensitive .env files. First, create the sample project files:

python src/db.py
# src/db.py
import sqlite3

def get_connection():
    return sqlite3.connect("app.db")

def get_user(user_id):
    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
    return cursor.fetchone()
python src/models.py
# src/models.py
class User:
    def __init__(self, id, name, email):
        self.id = id
        self.name = name
        self.email = email
bash .env
# .env — should NEVER be touched
DATABASE_URL=postgresql://admin:secret@prod-db:5432/myapp
API_SECRET=sk-prod-secret-key

Now the safe refactoring agent. It can read and write source files, but cannot run bash commands and cannot touch any .env file, regardless of what the model tries to do.

python safe_refactor.py
# safe_refactor.py
import asyncio
import harness
from harness.permissions.rules import PermissionConfig

rules = PermissionConfig()
rules.add_allow("Read")
rules.add_allow("Write")
rules.add_allow("Edit")
rules.add_allow("Glob")
rules.add_allow("Grep")
rules.add_deny("Bash")  # No shell access
rules.add_deny("Read",  args_pattern={"file_path": "*.env*"})   # Protect .env
rules.add_deny("Write", args_pattern={"file_path": "*.env*"})   # Protect .env
rules.add_deny("Edit",  args_pattern={"file_path": "*.env*"})   # Protect .env

async def main():
    async for msg in harness.run(
        "Refactor src/db.py to use parameterized queries and connection pooling",
        permission_mode="default",
        permission_rules=rules,
    ):
        match msg:
            case harness.ToolUse(name=name):
                print(f"  Tool: {name}")
            case harness.Result() as r:
                print(f"Done! {r.tool_calls} tool calls, ${r.total_cost:.4f}")

asyncio.run(main())
What the rules enforce

If the agent attempts Bash("rm src/db.py"), the call is blocked before execution. If it attempts Read(".env"), it's blocked. The agent receives an error message and adjusts its approach.

bash
uv run safe_refactor.py
6

4-Tier Precedence

When the agent requests a tool call, Harness evaluates it through four layers in order. The highest matching layer wins.

Tool Call Request
1
Explicit DENY rules
If any deny rule matches — by tool name or args_pattern — the call is immediately blocked. This layer always wins.
2
Explicit ALLOW rules
If an allow rule matches and no deny matched, the call proceeds automatically without prompting.
3
Policy engine rules
Org-level policies defined in policy.yml are evaluated. Useful for team-wide constraints applied to every agent run.
4
Permission mode fallback
The selected mode (default / accept_edits / plan / bypass) handles anything not covered by layers 1–3.
🔒
Deny always wins

If a deny rule matches, the tool call is blocked regardless of allow rules, policies, or permission mode. You cannot accidentally override a deny with an allow at a lower tier.

The policy engine (Tier 3) reads a policy.yml file from your project root or from ~/.harness/policy.yml for global rules. This enables team-wide constraints that apply automatically without configuring rules in each script.

yaml policy.yml
version: 1
rules:
  - tool: Bash
    decision: deny
    when:
      command_matches: "rm -rf*"
  - tool: WebFetch
    decision: deny
  - tool: Read
    decision: deny
    when:
      path_matches: "*.env*"
  - tool: Write
    decision: deny
    when:
      path_matches: "*.env*"

Policy files are evaluated after explicit SDK rules (Tiers 1–2) but before the mode fallback (Tier 4).

7

Custom Approval Callback

For workflows that need dynamic, context-aware approval — such as integrating with Slack, PagerDuty, or a human-in-the-loop UI — pass an approval_callback function to harness.run().

python
async def my_approval(tool_name: str, args: dict) -> bool:
    """Custom approval logic — could integrate with Slack, PagerDuty, etc."""
    # Auto-approve reads
    if tool_name in ("Read", "Glob", "Grep"):
        return True
    # Block dangerous patterns
    if tool_name == "Bash" and "rm" in args.get("command", ""):
        return False
    # Ask for everything else
    print(f"Approve {tool_name}? (y/n): ", end="")
    return input().strip().lower() == "y"

async for msg in harness.run(
    "Clean up the project",
    approval_callback=my_approval,
):
    ...
Try it — extend the callback

Replace the input() call with a Slack API call to send an approval request to a channel. Return True when an approver reacts with a checkmark emoji.

What competitors can't do

Claude Code has basic allow/deny with no programmable approval path. No other AI coding tool has 4-tier precedence, policy engine integration, or custom approval callbacks. Harness is the only agent with enterprise-grade permission architecture.

The callback signature supports both sync and async functions. For Slack integration:

python
import asyncio
import slack_sdk.web.async_client as slack

async def slack_approval(tool_name: str, args: dict) -> bool:
    # Auto-approve reads
    if tool_name in ("Read", "Glob", "Grep"):
        return True

    # Post to Slack and wait for reaction
    client = slack.AsyncWebClient(token=SLACK_TOKEN)
    msg = await client.chat_postMessage(
        channel="#agent-approvals",
        text=f"Agent wants to run `{tool_name}` with args:\n```{args}```\nReact with :white_check_mark: to approve."
    )
    # Poll for reaction (simplified)
    await asyncio.sleep(30)
    reactions = await client.reactions_get(channel=msg["channel"], timestamp=msg["ts"])
    approved_reactions = [r for r in reactions["message"].get("reactions", [])
                          if r["name"] == "white_check_mark"]
    return len(approved_reactions) > 0
8

Next Steps

You now have full control over what your agent can and cannot do — from broad permission modes to surgical allow/deny rules, policy files, and custom approval callbacks.

The next tutorial covers budget controls: setting hard spending limits, mid-session cost warnings, and automatic session abort when thresholds are exceeded.

Tutorial 4: Budget & Cost Controls

Set per-session and per-task spending limits. Receive warnings before you hit your budget. Abort automatically when limits are exceeded.