The safest tool for an agent is one where the harmful path does not exist

I gave an agent access to encrypted mail by leaving out the tool that sends, and by making every other failure return a code and not a story.

The server this comes from is a clean-room MCP server for an end-to-end encrypted mail service. The code is private, so no counts, mailbox details or service specifics appear here. The method, shown below on the standard library, is what a reader can check.

Check this yourself

The snippet holds two of the ideas. A reply is released only after its audit entry has been written, so when the log is down the caller gets a code and no data. And each entry hashes the one before it, so changing an old record breaks the chain from there on.

import hashlib, json

def link(prev, rec):
    return hashlib.sha256((prev + json.dumps(rec, sort_keys=True)).encode()).hexdigest()

class Log:
    def __init__(self, working=True):
        self.working, self.prev, self.lines = working, "0" * 64, []
    def write(self, rec):
        if not self.working:
            return False
        self.prev = link(self.prev, rec)
        self.lines.append({"rec": rec, "hash": self.prev})
        return True

def call(log, tool):
    reply = f"reply from {tool}"
    return reply if log.write({"tool": tool}) else "audit_unavailable"   # no entry, no reply

print(call(Log(), "list"))
print(call(Log(working=False), "list"))

def intact(lines):
    prev = "0" * 64
    for line in lines:
        prev = link(prev, line["rec"])
        if prev != line["hash"]:
            return False
    return True

log = Log()
for tool in ("list", "read", "search"):
    call(log, tool)
print(intact(log.lines))
log.lines[0]["rec"]["tool"] = "other"
print(intact(log.lines))
reply from list
audit_unavailable
True
False

It is a model of the rule, not the server. It needs only a stock python3.

The move

Remove the path instead of guarding it. The server offers a small fixed set of read tools and, when drafts are switched on, one that saves a draft. There is no tool that sends. An agent that has been talked into sending mail finds nothing to call, which is a stronger position than a tool that checks whether it should.

Every failure comes back as a code from a closed list, never as free text, so an error cannot carry mailbox content or a stack trace back to the model. Replies are bounded in size. Content the server cannot decode is reported unreadable, not guessed at. Anything that came from a message is marked as untrusted content, so the model is told which text is data and not instruction.

Before anything is decrypted, its authentication code is verified, and a failure stops there. The reply is then gated on the audit write shown above.

Why the obvious version fails

The obvious version exposes every mail action and relies on the agent, or a prompt, to choose well. That makes safety depend on the one component that reads attacker-written text for a living. A message in the mailbox can say what it likes.

The obvious error path returns the exception message, which is how private text leaks. The obvious log is best-effort, which means the calls that fail to log are the calls nobody can reconstruct. The audit-gated reply trades availability for a record, and I chose the record.

Verifying before decrypting fails the same way if skipped: a tampered message gets processed as if it were real.

What this does not cover

  • Reading is still reading. An agent that can read mail can read what is in it, and marking content untrusted lowers the risk without removing it.
  • The server does not restrict which account it is used with. That choice is made outside it.
  • The audit log is hash-chained, so tampering shows. It does not stop someone who can rewrite the whole file.
  • A saved draft is still a draft. A person sends it, from outside this server.
  • The snippet models the rule. The code is private, so the server itself cannot be re-run from here.

The same preference for removing a path shows up in approvals bound to the exact action and in agents that don’t trust each other. How I check the work itself is on verification, and the timing method is on GPU profiling. Back to the home page.