mech.app

The mech.app newsletter

Agentic AI, minus the noise.

Get practical field notes on AI agents, automation, developer tools and security delivered to your inbox.

No spam. Unsubscribe anytime.

Security

When AI Agents Delete Your Emails: Authorization Boundaries in Agentic Systems

A Meta researcher's agent deleted her inbox. Here's how to scope permissions, build confirmation loops, and prevent destructive actions in production.

Source: au.pcmag.com
When AI Agents Delete Your Emails: Authorization Boundaries in Agentic Systems

A Meta AI security researcher told her agent to confirm before acting. It deleted her inbox anyway. Summer Yue had to run to her Mac mini to stop OpenClaw from finishing the job.

This is not a prompt injection story. This is an authorization boundary failure. The agent had permission to delete emails, the inbox size triggered a compaction routine, and the original instruction to ask first got lost in context. The agent did what it was allowed to do, not what the human wanted it to do.

The gap between “can the agent do this?” and “should the agent do this?” is where production systems break. Here’s how to close it.

The Authorization Problem

Agents need access to perform tasks. Email agents need to read messages, file them, mark spam, and sometimes delete. But read and delete are not the same risk class.

Traditional OAuth scopes treat them the same. gmail.modify gives you both. The agent gets a token, the token grants a permission set, and the agent can use any permission in that set at any time. There is no runtime decision point between “I can delete” and “I will delete.”

Agentic systems make this worse because the agent decides which tool to call. A human writes code that calls delete_email(id) in a specific context. An agent writes a plan that includes delete_email(id) based on a heuristic, a misunderstood instruction, or a context window that dropped the safety constraint.

Permission Scoping Patterns

You have three architectural options for scoping agent permissions:

Read-only by default. The agent gets read access to all tools. Destructive operations require a separate approval flow. The agent can query, filter, and analyze, but it cannot delete, send, or purchase without human confirmation.

Capability tokens. Each tool call requires a specific token. The agent requests email:read and email:delete separately. The orchestrator grants email:read automatically and queues email:delete for approval. The agent does not hold a long-lived delete permission.

Action classification. The orchestrator inspects every tool call and classifies it as safe, risky, or destructive. Safe actions execute immediately. Risky actions log and execute. Destructive actions pause and wait for confirmation.

PatternLatencyAgent AutonomyFailure Mode
Read-only defaultLow for reads, high for writesLowAgent cannot complete tasks without human in loop
Capability tokensMediumMediumToken request logic adds complexity, agent may not request correct token
Action classificationLow to high depending on actionHighClassifier may misclassify, agent may route around restrictions

Read-only default is the safest but breaks autonomy. Capability tokens are the most flexible but require the agent to understand permission semantics. Action classification is the most transparent but depends on a reliable classifier.

Confirmation Loop Architecture

A confirmation loop is not a prompt. It is a state machine.

The agent proposes an action. The orchestrator evaluates the action against a policy. If the action is destructive, the orchestrator pauses execution, serializes the agent state, and sends a confirmation request to the human. The human approves or rejects. The orchestrator resumes execution with the decision.

Here’s what that looks like in code:

class AgentOrchestrator:
    def __init__(self, policy: DestructiveActionPolicy):
        self.policy = policy
        self.pending_confirmations = {}

    async def execute_tool_call(self, agent_id: str, tool: str, params: dict):
        action = ToolAction(tool=tool, params=params)
        
        if self.policy.is_destructive(action):
            confirmation_id = self.request_confirmation(agent_id, action)
            # Serialize agent state and pause
            await self.pause_agent(agent_id)
            # Wait for human decision
            decision = await self.wait_for_confirmation(confirmation_id)
            
            if decision.approved:
                result = await self.call_tool(tool, params)
                await self.resume_agent(agent_id, result)
            else:
                await self.resume_agent(agent_id, error="Action rejected by user")
        else:
            result = await self.call_tool(tool, params)
            return result

    def request_confirmation(self, agent_id: str, action: ToolAction) -> str:
        confirmation = {
            "agent_id": agent_id,
            "action": action,
            "timestamp": time.time(),
            "context": self.get_agent_context(agent_id)
        }
        confirmation_id = generate_id()
        self.pending_confirmations[confirmation_id] = confirmation
        self.send_to_user_interface(confirmation)
        return confirmation_id

The critical pieces are state serialization and resumption. The agent cannot continue planning while waiting for confirmation. If it does, it will generate more actions that depend on the unconfirmed action, and you will have a dependency graph of pending confirmations that cannot be resolved.

Audit Trail Requirements

You need to log three things: what the agent did, what it considered doing, and why it chose a particular action.

Standard application logs capture execution. Agent logs need to capture reasoning. When an agent deletes an email, the log should show:

  • The tool call: delete_email(id="msg_12345")
  • The plan step that generated the call: “User said inbox is too full, delete low-priority messages”
  • The context that informed the plan: “Inbox has 10,000 messages, user instruction was ‘clean up my inbox’”
  • The decision point: “Classified message as low-priority based on sender and subject”

If the agent deletes the wrong email, you need to trace back through the reasoning chain to find where it went wrong. Was the classification wrong? Was the instruction ambiguous? Did the context window drop a constraint?

Structured logging helps:

@dataclass
class AgentActionLog:
    timestamp: float
    agent_id: str
    tool: str
    params: dict
    plan_step: str
    context_summary: str
    decision_rationale: str
    risk_level: str
    confirmation_required: bool
    confirmation_result: Optional[str]
    execution_result: dict

This log format supports post-incident analysis. You can query for all destructive actions, all actions that required confirmation, or all actions where the agent’s rationale mentioned a specific keyword.

Policy Definition

A destructive action policy is a function that takes a tool call and returns a risk level. The simplest version is a static map:

DESTRUCTIVE_TOOLS = {
    "delete_email": "destructive",
    "send_email": "risky",
    "mark_as_spam": "safe",
    "archive_email": "safe"
}

This works until you have a tool that is sometimes destructive. send_email is risky if the recipient is external, safe if the recipient is the user. delete_email is destructive if the email is unread, less risky if it is read and old.

A better policy is a rule engine:

class DestructiveActionPolicy:
    def is_destructive(self, action: ToolAction) -> bool:
        if action.tool == "delete_email":
            email = self.fetch_email(action.params["id"])
            if email.unread or email.age_days < 7:
                return True
        if action.tool == "send_email":
            if self.is_external_recipient(action.params["to"]):
                return True
        return False

The policy can call external services to evaluate risk. It can check if an email is part of a thread, if a recipient is on a VIP list, or if a purchase amount exceeds a threshold.

Failure Modes

Even with confirmation loops and policies, agents will do the wrong thing. Here are the common failure modes:

Context window loss. The agent receives an instruction with a constraint. The constraint is in the first message. The agent processes 50 messages of email metadata. The constraint falls out of the context window. The agent generates a plan without the constraint.

Ambiguous instructions. “Clean up my inbox” could mean delete, archive, or mark as read. The agent picks one. The human meant another.

Confirmation fatigue. The agent asks for confirmation 20 times. The human approves all 20 without reading. One of them is wrong.

Race conditions. The agent proposes a destructive action. The orchestrator sends a confirmation request. The human approves. The orchestrator resumes the agent. The agent’s context has changed, and the action no longer makes sense.

Policy drift. The policy is defined at deployment time. The agent’s behavior evolves. The policy does not keep up. Actions that should require confirmation do not.

Technical Verdict

Use confirmation loops for any agent that can perform destructive actions. Define destructive as any action that cannot be undone or that affects data outside the agent’s sandbox.

Use read-only defaults for agents in early development or agents that operate on high-value data. Trade autonomy for safety until you have confidence in the agent’s decision-making.

Use capability tokens for agents that need fine-grained access control or agents that operate in multi-tenant environments. The token request flow adds complexity but gives you runtime visibility into what the agent is trying to do.

Avoid relying on the agent to self-regulate. “Confirm before acting” is an instruction, not a constraint. Instructions can be forgotten, misunderstood, or overridden by other instructions. Constraints must be enforced by the orchestrator.

Build audit trails from day one. You will need them for debugging, compliance, and post-incident analysis. Structured logs with reasoning chains are worth the storage cost.