Skip to content

Flow guard

A small information-flow check that sits on top of Octipus’s ALLOW/ASK/DENY permissions. It borrows the core idea of OpenAPPA: keep a label per session that only gets stricter, and check every tool call against it before it runs. It does not include OpenAPPA’s policy language, sanitizers, annotators or remedy plans; the existing approval flow fills that role.

Each session (the root agent and its children, which share a sessionId) carries three flags. Once a flag is set, it stays set for the session.

FlagSet after readingExamples
suspicioustext written by people outside the user’s controlweb search/fetch, browser, email, chat messages, GitHub/GitLab/Jira items, external MCP results, Claude WebFetch/WebSearch, Codex web_search
privatethe user’s own datamail, calendar, drive, docs, chat, data queries
secretcredential material.env, ~/.ssh, ~/.aws, .npmrc, .git-credentials, .kube, /proc/*/environ, private keys; through the filesystem tool, a shell command, or a CLI Read/Bash

Using the vault never sets a flag. A {{secret:NAME}} placeholder is resolved inside the tool after the guard has run, and the resolved value is masked in the output, so the model never sees the credential. The secret flag is only set by reading raw credential files.

A call that authenticates through the vault is also exempt from the check, even in a session that has been tainted. This applies when every {{secret:NAME}} in the arguments names an active vault entry that the tool is allowed to use. Tools leave an unresolved placeholder as plain text, so a made-up name does not qualify. Vendor-native CLI tools never resolve placeholders, so they never qualify either. The vault is only queried when the guard would otherwise ask, and the query checks access without decrypting anything. The trade-off: in a tainted session, a vault-authenticated call can send data out without asking.

A static contract for each tool decides which flags the call sets, and whether the call can send data out (“egress”):

  • read egress: only a URL or query leaves (web search, fetch, navigate).
  • write egress: sending mail or messages, posting to GitHub/GitLab/Jira, git push, calendar invites, non-read MCP tools, and shell commands that use the network (curl, wget, scp, ssh, gh pr, npm publish, …).

The guard can change a call’s level from ALLOW to ASK. It never allows a call that would otherwise be refused, and a DENY is left unchanged. It asks in three cases:

  1. The session has read a secret, and the call is any egress.
  2. The session has read private data and untrusted content, and the call is a write egress. This is the “lethal trifecta”: private data, untrusted instructions and a way to send data out.
  3. The session is a member’s conversation in a group channel, and the call reads private data. The reply is posted where every member can read it, so the read itself is the egress. Such sessions also start suspicious, since their prompt carries other members’ messages.

The call’s own reads are counted too, so cat .env | curl -d @- … is caught in one step.

In an attended session, the escalated call goes through the normal approval prompt, and the permission_request event includes the reason. An approval covers that one call only, and the label stays as it was. An unattended run (API, cron, hook) cannot prompt, so the call is blocked. The model receives a single line such as:

Approval required: flow guard: this session read credential material (filesystem:read); sending anything out needs approval. Do NOT retry this action — it is blocked by policy.

Most write sinks (messaging:send, email_send, github:write, git:push) already default to ASK. The guard adds friction where a standing ALLOW grant, a rule, or a default ALLOW would otherwise let a tainted session send data out, for example MCP tools, web fetches, or shell network commands.

An interactive answer is not a tool call, so the guard never asks about one. It has no egress of its own: its frames reach no network, and links and suggested messages need the user’s click on the chat page. The session’s label is recorded on each answer when it is stored:

  • tainted (any flag set): its script waits for Run, and it cannot be saved as an artifact;
  • high risk (secret, or private together with suspicious): the Run prompt warns that the answer could send what it shows out of the page.
  • Tokens: nothing is added to prompts or tool results. The only extra text is the one-line reason on a refused call.
  • Runtime: one in-memory map lookup and a few regular expressions per tool call. It makes no LLM calls, no network calls and no database writes.
PathWhere calls are checkedWhere reads are recorded
Direct providersToolExecutor before the permission routeafter each successful call
CLI Octipus tools (bridge)ToolExecutor (same as direct providers)same
Claude Code native toolsstdio permission relay (cli-permissions.ts)the output stream (cli_tool_use), so auto-allowed Read/Bash count too
Codex native toolsnot checked (no per-call relay; the sandbox and shell guard still apply)the output stream (command_execution, web_search)

Limits:

  • Labels are kept in memory (LRU, 2 000 sessions). A restart clears them, which means the guard asks less often, not more.
  • Contracts judge a tool and its arguments, not the text a tool returns. Content the model reads without calling a tool is not labelled.
  • Codex can taint a session, but the guard cannot stop its native shell. Its bridged Octipus tools are still checked.

agent.flowGuard: ask (default) or off. Environment variable: AGENT_FLOW_GUARD=off.

The code is in src/security/flow-guard.ts. classifyFlow holds the contracts; extend it when you add a tool that reads untrusted or private data, or one that sends data out.


Edit this page in the repository.