Post 05

Permissions Are Product Design

Once an agent can inspect and change a workspace, the next question is not only what is safe. It is what should feel trustworthy to the person using it.

Hand-drawn derailleur diagram showing permissions as a way to shift between different action modes.

In the last post, we gave the agent a workspace. That workspace tells the tools where they are allowed to act.

But path containment is only the first safety boundary. A path can be inside the workspace and still be a bad idea to touch.

write_file("src/app.py", new_code)
write_file(".env", "API_KEY=...")
write_file(".loop/PROGRESS.md", "done")
write_file("README.md", generated_summary)

All of these paths may be inside the project. They should not all be treated the same.

The Derailleur

Permissions are not only the brake. They are also the derailleur: the mechanism that shifts an action onto the right track.

Reading a normal file can run in an "easy gear". Listing a directory is even safer. Editing source code moves to a higher-risk gear. Running shell commands or touching secrets should move into a gear that requires much more control, or stops completely.

Permission Is Not One Bit

So should the agent be allowed to write files or not?

Real permission design is deeper than that. The harness needs to decide between at least three outcomes:

allow  run the action automatically
deny   block the action and explain why
ask    pause and ask the user first

Those three states are already product design. They shape how bold the agent feels, how much interruption the user sees, and how much trust the system earns.

The Permission Surface

A useful permission check looks at more than the tool name. It looks at the full action.

{
  "tool": "write_file",
  "path": "src/app.py",
  "operation": "overwrite",
  "reason": "update the route handler",
  "risk": "changes source code"
}

The harness can use that surface to make better decisions. Reading README.md is low risk. Reading .env is not. Writing summary.txt is different from overwriting pyproject.toml.

Reads Are Not All Equal

Developers often talk about write permissions first because writes feel dangerous. But reads matter too, if not more! A coding agent may be able to read secrets, customer data, tokens, private notes, or unrelated projects.

That means read tools need policy as well.

LOW_RISK_READS = (
    "README.md",
    "src/",
    "tests/",
    "docs/",
)

BLOCKED_READS = (
    ".env",
    ".ssh/",
    "secrets/",
)

The exact lists will vary by product. The point is the habit: do not treat every readable file as equally safe to place in model context.

Writes Need Shape

A write is also not one thing. Creating a new draft file, overwriting source code, editing lockfiles, deleting a directory, and changing generated artifacts are different actions.

create new file       usually low risk
overwrite file        medium risk
edit source code      medium or high risk
edit config           high risk
delete file           high risk
write secrets         block

This is why a single write_file tool becomes more useful once the harness can describe what kind of write it is about to perform.

Ask Is A Product Moment

Asking the user is not just a boolean permission gate. It is a product moment. The user should understand what the agent wants to do, why it wants to do it, and what could go wrong.

The agent wants to overwrite:
  src/app.py

Reason:
  update the route handler to read notes.txt

Risk:
  changes source code

Approve? [y/N]

A vague prompt like "Allow tool call?" is not enough. The confirmation should translate the tool call into human terms.

You will implement real interactive confirmation in the CLI post. For now, the important idea is that the policy layer should be able to say: this action is not denied, but it needs a person.

Approval Has Scope

Real coding agents go one step further. They do not only ask "yes or no?" They ask how long that answer should last.

Claude Code has permission modes for different working styles, from asking before edits to accepting edits, planning, auto mode, and locked-down pre-approved tools. Codex has a similar product idea through approval modes and permission profiles: read-only, workspace editing, broader sandboxed autonomy, and custom filesystem or network boundaries.

allow once
allow for this session
always allow this kind of action
deny this action
always deny this kind of action

This is where permission design becomes a first-class product concern. If the agent asks before every test command, the user gets tired and starts approving without thinking. If the agent never asks, the user loses the chance to stop a bad action.

Rules Need Shape Too

A "don't ask again" button should not mean "the agent can do anything now." It should create a narrow rule.

# too broad
always allow shell commands

# better
allow for this session:
  run: .venv/bin/python -m pytest

# persistent, but still narrow
always allow:
  read files under docs/
  run npm test

# hard boundary
always deny:
  write .env
  git push
  remove files outside workspace

This is the same idea we used for tools: smaller surfaces are easier to trust. Permission rules should be specific enough that the user can remember what they approved.

Permissions Become Capabilities

Once the agent grows beyond file tools, permissions stop being a small guard around read_file and write_file. They become the capability model for the whole agent.

A capability is a door the agent can use. Some doors lead to local files. Some lead to external systems. Some lead to memory, deployment, or other agents.

tool permissions       which tools can run, with which arguments
MCP permissions        which servers, tools, and resources are trusted
context permissions    what may be placed into model context
network permissions    which URLs, APIs, and services are reachable
environment permissions which env vars and credentials are visible
git permissions        branch, commit, push, merge, deploy
memory permissions     what can be saved, inspected, or forgotten
delegation permissions whether agents can spawn or approve other agents

MCP makes this especially visible. An MCP server can expose tools, resources, and prompts from outside the local workspace. That can be powerful, but it also means the permission model has to decide which external doors are open and what context is allowed to pass through them.

The same product question repeats at every layer: what should be smooth, what should be visible, and what should be impossible?

Policy Result

Let's make that explicit in code. Instead of returning only true or false, policy can return a structured result.

def allow() -> dict:
    return {"decision": "allow"}


def deny(message: str) -> dict:
    return {
        "decision": "deny",
        "message": message,
        "retryable": False,
    }


def ask(message: str, risk: str, rule_hint: str) -> dict:
    return {
        "decision": "ask",
        "message": message,
        "risk": risk,
        "rule_hint": rule_hint,
        "approval_scopes": ["once", "session", "always"],
        "retryable": False,
    }

This gives the harness room to grow. Today, ask can behave like a block because we do not have a CLI confirmation flow yet. Later, the CLI can show the message to the user and continue if they approve.

A Small Policy Function

Here is a simple policy check for our current tools. It blocks obvious sensitive reads, blocks internal .loop writes, and marks source-code writes as actions that should ask the user in a real CLI.

SENSITIVE_PATHS = (
    ".env",
    "secrets/",
)

INTERNAL_PATHS = (
    ".loop/",
)

SOURCE_PATHS = (
    "src/",
    "tests/",
)


def path_starts_with(path: str, prefixes: tuple[str, ...]) -> bool:
    return any(path == prefix.rstrip("/") or path.startswith(prefix) for prefix in prefixes)


def check_policy(name: str, arguments: dict) -> dict:
    path = arguments.get("path", "")

    if name == "read_file" and path_starts_with(path, SENSITIVE_PATHS):
        return deny("The agent may not read sensitive files.")

    if name == "write_file" and path_starts_with(path, INTERNAL_PATHS):
        return deny("The agent may not write internal loop state.")

    if name == "write_file" and path_starts_with(path, SENSITIVE_PATHS):
        return deny("The agent may not write secrets.")

    if name == "write_file" and path_starts_with(path, SOURCE_PATHS):
        return ask(
            message=f"The agent wants to modify source code: {path}",
            risk="changes code that may affect behavior",
            rule_hint=f"write_file:{path}",
        )

    return allow()

This is not a perfect security system. It is a first product-shaped policy layer. The important improvement is that different actions now produce different decisions.

Send Policy Back To The Model

When the harness blocks an action, the model should get a clear result. Otherwise it may keep trying the same thing in slightly different words.

{
  "ok": false,
  "tool": "write_file",
  "error_type": "permission",
  "message": "The agent may not write secrets.",
  "retryable": false
}

A clear policy result helps the model recover. It can pick another path, explain the limitation, or ask the user for a different instruction.

The Loop So Far

For now, update check_policy in the previous loop with the structured permission result above. Then treat ask as blocked until we build a real CLI confirmation flow.

def policy_to_tool_result(name: str, policy: dict) -> dict:
    if policy["decision"] == "allow":
        return {"ok": True}

    return {
        "ok": False,
        "tool": name,
        "error_type": "permission",
        "message": policy["message"],
        "retryable": policy.get("retryable", False),
        "needs_approval": policy["decision"] == "ask",
        "risk": policy.get("risk"),
        "rule_hint": policy.get("rule_hint"),
        "approval_scopes": policy.get("approval_scopes", []),
    }

That small field, needs_approval, is the bridge to the next post. The harness can already identify that an action needs a person. The rule hint and approval scopes tell the CLI whether it can offer "once", "session", or "always" without inventing policy in the UI.

Further Reading

If you want to see how production coding agents expose these ideas, read the Claude Code permission modes and Claude Code permissions docs. For a filesystem and network profile angle, see Codex permission profiles.

Where we are

Permissions are not only about preventing disaster. They are how the product explains the agent's power to the user.

Next we will give the loop something we have been waiting for, a proper CLI.