Permission Modes & Safety Controls | Harness

# 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 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

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.