How Porticol stops an agent deleting production

In April 2026 a Cursor agent working on PocketOS found a Railway token in a file it could read and deleted the production database and its backups in nine seconds. The request was ordinary, the token readable, nobody had to approve it. The founder wrote the account in his post.

An agent that can read a key can use it. Guard works on two facts: the key does not have to be on the laptop, and an irreversible call does not have to run without a person.

Three levels, stated plainly

Guard is one policy with one decision function, used by three paths.

  • Guided (level 1) is the agent calling through Porticol's MCP server. The key never reaches the model, a risky call waits, and every call is recorded. This stops mistakes, not an agent that finds a key somewhere else and runs curl itself.
  • Enforced (level 2) puts the local proxy in the path of the agent's shell. On macOS and Linux, Claude Code's sandbox sends its shell traffic through it, and Codex's network proxy routes sandboxed commands to it. No production key sits on disk. It does not stop someone turning the sandbox off or a command that leaves the proxy. On Windows only levels 1 and 3 run.
  • Gated (level 3) keeps production keys on the team gateway, and production accepts calls from the gateway alone. This is the level that stops an agent on any laptop.

Shell commands take a second path. Claude Code and Codex leave an HTTP call to the local proxy, and ask (Claude Code) or deny (Codex) a command that changes how its calls reach it. Cursor has no proxy, so its hook decides one plain curl, wget or httpie call, asks on a command it cannot read, and waits 25 seconds for a person.

A DELETE, end to end

Take DELETE /users/42 against production, from an agent.

  1. The policy matches. send and run call decide before anything is sent. It checks the agent's ceiling first: a call above the environment that agent may reach is denied before any rule. Then the first matching rule wins. Other writes on production ask, and an unmatched call on a protected environment asks by default, which is how the DELETE below is held.
version: 1
mode: enforce
hosts:
  api.stripe.com: { env: production, key: "{{stripe_live_key}}" }
rules:
  - match: { env: production, method: [POST, PUT, PATCH] }
    action: ask
agents:
  claude-code: { up_to: production }
  "*": { up_to: staging }

{{stripe_live_key}} names a secret variable of the production environment. Its value stays in the keychain, and Porticol adds it to the request.

  1. The approval row is written, bound to the request. For an ask, Guard stores an id, the agent, the rule, the environment, the method, the redacted URL, a body preview and the agent's reason. Over MCP, request_hash is a SHA-256 of the method, URL, environment, headers, auth, protocol, settings and body; through the local proxy it is the method, URL, environment, headers and body the proxy saw. A JSON body is canonical, with sorted keys and no whitespace; any other body is hashed as its raw bytes. The row expires after 10 minutes and can be used once.

  2. The card asks a person. The desktop shows a card over any tab: the agent's name, the environment, the exact request with keys masked, and the agent's reason in its own words. The buttons are Allow once, Allow for today and Deny. A system notification fires while the app is in the background. porticol-cli approve <id> and deny <id> do the same from a terminal, with the four-letter code on the card, because an agent's shell has no interactive terminal.

  3. The decision reaches the call. An MCP send or run holds for up to 45 seconds, polling the row; Codex's default tool timeout is 60 seconds. If the person approves in time, the call goes through. If nobody answers, the agent gets:

{
  "status": "pending",
  "approval_id": "8f0c…",
  "reason": "DELETE to production needs a person.",
  "message": "A person must approve this call in Porticol. Call send again with the same request and approval_id once they have. Do not loop and do not ask for a secret."
}

A deny returns {"status": "denied", "reason": "…", "rule": "…"} with "Do not retry this call." The local proxy holds an ask for 60 seconds, then answers 403 with the approval id and how to retry it.

  1. The retry is checked, then spent. The agent retries once with the same request and the approval_id. Guard recomputes the hash: the row must be approved, unexpired, unused and identical. If it is, the row becomes used in the same transaction as the send. A changed request fails. The built-in prompts say never loop and never ask for a secret.

  2. The log entry is appended. Each entry holds the time, agent, path, environment, method, URL, body hash, rule, decision, approval id, approver, approval time, status, latency and response hash. entry_hash is a SHA-256 over the previous hash and the canonical JSON of every other column; the first entry starts from 64 zeros, and no command updates a row. porticol-cli guard log verify checks the chain.

"Allow for today" approves the row and adds an allowance for the rule, agent and environment until local midnight: one call approved, or a rule accepted for a day.

What it does not stop

  • Level 1 stops mistakes, not an agent that finds a key and runs curl.
  • Level 2 for Claude Code does not stop someone turning the sandbox off.
  • Cursor's hooks can ask and deny shell and MCP calls, but they are not a forced network path.
  • Codex's network proxy is experimental and filters sandboxed commands; its hooks deny, and cannot ask.
  • The agent's name in the log is self-reported. It identifies the client, not a person.
  • Windows runs levels 1 and 3 only.
  • Guard does not replace scoped keys, read-only roles or backups.

The policy engine, the local proxy, the CLI, the MCP server and agent setup are open source under Apache-2.0 at github.com/pavanmanishd/porticol-cli. The desktop app, team features and gateway stay proprietary.

Start at level 1 with no account, tune a policy in shadow mode, and move to level 3 when the production key must never be on a laptop.