- Book: AI Agents Pocket Guide
- Also by me: LLM Observability Pocket Guide
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
April 2026. According to reports from SecurityBrief Asia, Trending Topics, and Foresiet's April 2026 incident roundup, an engineer at Meta posted a routine technical question on an internal forum. A colleague turned to one of Meta's in-house AI agents to draft a response. Per those accounts, the agent, operating with valid service-account credentials, retrieved internal data and posted instructions that other employees in the thread then followed, surfacing information to staff outside the original access scope. The reports describe roughly two hours of uncontrolled exposure, a Sev-1 internal alert, no external attacker, no phishing payload, no CVE.
As reported by those secondary outlets citing The Information, Meta confirmed the incident, classified it as Sev-1, and stated there was no evidence of external exploitation.
The recurring characterization across the cited write-ups: the agent held broad service-account permissions and had no data-classification layer between its tool calls and its outputs. It wasn't compromised. It executed its instructions. The reports characterize this as a design-level failure rather than a compromise.
This is the new failure shape. The agent is the attack surface. The defensive layer goes between the agent loop and the world.
What "the agent is the surface" actually means
In the legacy model, you protect resources from users. Authn says who they are, authz says what they can touch, audit logs say what they did. The threat model assumes a hostile actor on one side of the boundary and a trusted system on the other.
An autonomous agent breaks this model in three specific places:
- It runs with a stable service account that has the union of permissions needed for everything it might be asked to do.
- It chains tool calls in ways the static authz policy never enumerated, so the policy can't reason about the chain.
- Its output is text that humans then act on. The output itself is a side channel for whatever the agent retrieved.
In the timeline described by the SecurityBrief and Trending Topics accounts, the third point is what carried the data out. The agent didn't email anything externally. It posted into an internal forum. Other employees read the post and ran the steps in it. The data crossed an authorization boundary inside the company. The cause wasn't an exploit; the agent's output didn't carry the classification of the data it was built from.
The defensive shape is middleware
The control that would have contained this lives between the agent and the rest of the system. Three responsibilities, all enforced before the agent's tool output is returned to the loop or to a human:
- Tag every tool-call input and output with a data classification. Public, internal, confidential, restricted. Tags propagate through the agent's reasoning — every chunk of context the agent sees carries the highest classification of any source it came from.
-
Filter outbound text against the classification of who's reading it. A response composed from
restrictedsources cannot be returned to aconfidential-cleared destination. PII and secret patterns are scrubbed regardless. - Bound the agent's scope of action by role. The agent that drafts forum replies does not have the toolset to query payroll. The agent that queries payroll does not post to forums.
Item 3 is the boring one and the most important — most agent-incident postmortems collapse to "this agent could do too many things." But the middleware that handles 1 and 2 is what catches the residual class of failures, and it's what most teams skip.
A Python middleware that intercepts agent outputs
Drop this between your tool registry and your agent loop. It tags, scans, and redacts before the output is returned upward.
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Any
Classification is an ordered enum. Higher values are more sensitive. Outputs inherit the max classification of their inputs.
class Classification(int, Enum):
PUBLIC = 0
INTERNAL = 1
CONFIDENTIAL = 2
RESTRICTED = 3
@dataclass
class TaggedPayload:
content: str
classification: Classification
sources: list[str] = field(default_factory=list)
def merge(self, other: "TaggedPayload") -> "TaggedPayload":
return TaggedPayload(
content=self.content + "\n" + other.content,
classification=max(
self.classification, other.classification
),
sources=self.sources + other.sources,
)
The PII scanner. A small regex set first — these are the cheap, deterministic catches. The ML pass runs only on payloads above INTERNAL.
PII_PATTERNS = {
"email": re.compile(
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
),
"ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
"credit_card": re.compile(
r"\b(?:\d[ -]*?){13,16}\b"
),
"aws_key": re.compile(r"AKIA[0-9A-Z]{16}"),
"phone_us": re.compile(
r"\b\+?1?[ .-]?\(?\d{3}\)?"
r"[ .-]?\d{3}[ .-]?\d{4}\b"
),
}
def regex_scan(text: str) -> list[tuple[str, str]]:
hits = []
for name, pat in PII_PATTERNS.items():
for m in pat.finditer(text):
hits.append((name, m.group(0)))
return hits
The redactor. The contract: it does not raise on a hit. It returns a redacted copy and the list of redactions, so the agent loop can see what was filtered without seeing the values.
def redact(
text: str, hits: list[tuple[str, str]]
) -> tuple[str, list[str]]:
redacted = text
summary = []
for name, value in hits:
redacted = redacted.replace(
value, f"[REDACTED:{name.upper()}]"
)
summary.append(name)
return redacted, summary
Optional ML hook for richer entity detection. Spacy, Presidio, or your in-house classifier. Keep the interface narrow.
@dataclass
class MLDetector:
detect: Callable[[str], list[tuple[str, str]]]
def hybrid_scan(
text: str, ml: MLDetector | None
) -> list[tuple[str, str]]:
hits = regex_scan(text)
if ml is not None:
hits.extend(ml.detect(text))
return hits
The middleware. Wraps a tool call, tags the result, scans, and redacts according to the destination's clearance.
@dataclass
class Destination:
name: str
max_classification: Classification
class AccessDenied(Exception):
pass
class AgentMiddleware:
def __init__(self, ml: MLDetector | None = None):
self.ml = ml
self.audit: list[dict] = []
The class holds the ML hook and an audit log. The work happens in filter_for_destination — classification check first, then scan, then redact.
def filter_for_destination(
self,
payload: TaggedPayload,
destination: Destination,
) -> TaggedPayload:
if (
payload.classification
> destination.max_classification
):
raise AccessDenied(
f"{destination.name} cannot receive "
f"{payload.classification.name}"
)
hits = hybrid_scan(payload.content, self.ml)
if hits:
cleaned, summary = redact(payload.content, hits)
self.audit.append(
{
"destination": destination.name,
"classification": payload.classification.name,
"redactions": summary,
"sources": payload.sources,
}
)
return TaggedPayload(
content=cleaned,
classification=payload.classification,
sources=payload.sources,
)
return payload
A wrapper around an arbitrary tool function. The tool declares the classification of what it returns. The middleware enforces the rest.
@dataclass
class TaggedTool:
name: str
fn: Callable[..., str]
classification: Classification
def call(self, **kwargs: Any) -> TaggedPayload:
result = self.fn(**kwargs)
return TaggedPayload(
content=result,
classification=self.classification,
sources=[self.name],
)
Putting it together. Two tools, two destinations. The agent loop calls a tool, the middleware filters for the destination, the destination receives only what its clearance allows.
def example():
payroll = TaggedTool(
name="payroll_query",
fn=lambda employee_id: (
f"emp={employee_id} salary=190000 "
"ssn=123-45-6789"
),
classification=Classification.RESTRICTED,
)
forum = Destination(
name="internal_forum",
max_classification=Classification.INTERNAL,
)
mw = AgentMiddleware()
payload = payroll.call(employee_id="E42")
try:
mw.filter_for_destination(payload, forum)
except AccessDenied as e:
print(f"blocked: {e}")
Two things are now true. First, a RESTRICTED payload cannot be posted to an INTERNAL destination — the classification check fails before the redaction layer even runs. Second, even if a destination's clearance is correct on paper, the regex+ML scan strips known PII patterns before the text leaves the boundary. The audit log records what was filtered, so the security team has a forensic trail.
Why classification has to live in the payload
A common mistake: putting the classification in a sidecar — a metadata table, a tag service — and looking it up by ID. This breaks the moment the agent rewrites the content. Paraphrase a RESTRICTED document and the rewritten text has no ID, no tag lookup, no protection. The rewrite is now PUBLIC because nothing in the system says otherwise.
The middleware above ties classification to the payload object. Anything the tool returns is tagged. Any merge of two payloads takes the max. The agent can paraphrase all it wants — the wrapper carries the tag.
As the SecurityBrief and Foresiet write-ups describe it, the data was visible inside the company, in a forum, posted by an agent whose output pipeline did not propagate any classification tag from the underlying sources. A payload-bound tag is the cheapest control that breaks that chain.
Scope-of-action limits per agent role
The middleware contains the data once it's been retrieved. The other half of the defense limits what each agent can retrieve at all. The pattern:
- One agent role per coarse-grained capability. The forum-helper agent gets read access to the public knowledge base and write access to the forum. It does not get the payroll tool.
- Tool registries are per-role, not global. The payroll-query tool is registered on the HR-agent registry, full stop.
- A separate orchestrator decides which agent handles which user request. The orchestrator is the only thing that can route across roles.
This is the agent equivalent of the principle of least privilege, applied to tool surfaces. It's also the part of the design most often skipped, because spinning up a single agent with all the tools is faster and the failure mode is invisible until an incident like the Meta one makes it visible.
What the postmortem signal tells you to build
Reading across the SecurityBrief, Trending Topics, and Foresiet write-ups, three controls keep being named: data-centric guardrails on tool calls, output filters that scan before returning, and per-agent role scoping. The middleware above is the implementation of the first two. The third lives in your registry.
None of these stop a malicious actor with shell on the box. They stop the failure shape that the Meta incident actually exhibited — an agent doing what it was asked to do, with permissions broader than the task required, returning text that crossed a boundary the agent couldn't see.
The threat model has shifted. Your defensive controls should shift with it. If your current agent-tool registry is a flat list with no classification tags, no output filtering, and no per-role scoping, the calendar says you have a window to fix that before the incident shows up in your own postmortem.
If this was useful
Agent-tooling middleware, classification propagation, and PII scanning are exactly the patterns covered in AI Agents Pocket Guide — including the failure modes that motivate each one and the deployment patterns for production agent stacks. The audit-log instrumentation that makes incidents like the Meta one investigable lives in LLM Observability Pocket Guide: what to put on a tool span, how to track classification through agent reasoning, and how to detect cross-classification leaks before they hit Sev-1.


Top comments (0)