Permission Modes & Safety Controls
Control exactly what your AI agent can read, write, and execute — with enterprise-grade precision.
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.
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.
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.
The Four Permission Modes
Select the mode that matches your trust level and use case.
| 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 |
Same Task, Four Modes
Pass --permission on the CLI to control mode. Watch how the agent's
behaviour changes for the identical task.
# 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 |
Start with plan mode to get a safe analysis, review the plan, then re-run with accept_edits to apply the changes.
Permission Rules (Allow/Deny)
For fine-grained control, use PermissionConfig to define explicit
allow and deny rules that target individual tools and argument patterns.
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,
):
...
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 name | What it controls |
|---|---|
Read | Reading file contents |
Write | Writing new files |
Edit | Editing existing files (targeted replacements) |
Glob | File pattern matching / directory listing |
Grep | Searching file contents with regex |
Bash | Running shell commands |
WebFetch | Fetching URLs over HTTP |
Task | Spawning sub-agent tasks |
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:
# 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()
# src/models.py
class User:
def __init__(self, id, name, email):
self.id = id
self.name = name
self.email = email
# .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.
# 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())
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.
uv run safe_refactor.py
4-Tier Precedence
When the agent requests a tool call, Harness evaluates it through four layers in order. The highest matching layer wins.
policy.yml are evaluated. Useful for team-wide constraints applied to every agent run.default / accept_edits / plan / bypass) handles anything not covered by layers 1–3.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.
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).
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().
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,
):
...
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.
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:
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
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.
Set per-session and per-task spending limits. Receive warnings before you hit your budget. Abort automatically when limits are exceeded.