Sending email is not a reversible tool call
Most of what an agent does can be undone. A bad file write gets reverted. A bad query gets re-run. A bad call against a staging environment gets forgotten by Monday. Email is not on that list. Once SES accepts a message it is gone — delivered, sitting in somebody else's mail store, and counted against your domain's reputation whether or not you meant to send it.
So the question for anyone wiring an email tool into an agent is not whether the model will usually get it right. It is what happens the one time it doesn't.
The failure people picture is a hallucination. The more interesting one is prompt injection. Your agent reads a support ticket, a scraped page, a PDF someone attached — and the contents of that document become part of what it decides to do next. If the agent is holding a credential that can send mail to anyone, at any volume, as any verified identity in the account, then the authority of that document is the authority of the credential. One hallucination or one poisoned document away from forty thousand emails at 3am and a burned sending domain.
The uncomfortable part is that you can't fix this by asking the model to restrain itself. A rule in a system prompt lives in the same context window the attacker is writing into. A rule in a tool description lives there too. A rule in the MCP server's config lives on a machine the agent is already driving. Any check that runs inside the blast radius is not a check — it's a suggestion with good intentions.
The available options all fail in a specific way, and it's worth naming which. A raw SES key or IAM user is unbounded by construction: the ceiling is the account, and there is no per-agent anything. An ESP API key is worse for a different reason — you're sending on shared IPs, shared reputation is the provider's actual product, and autonomous traffic is a moderation problem they will eventually solve by clamping down on you. Agent-native mailbox providers give you the address but the same rented rails and the same exposure. And model-side guardrails, as above, are inside the thing you're trying to bound.
What's missing is boring and structural: an agent with its own email identity, a credential scoped to that identity, and a policy enforced somewhere the agent cannot reach.
Where the constraint has to live
Wraps puts it in a Lambda in your account, called wraps-agent-enforcer. The agent does not get SES. It gets permission to invoke that one function and nothing else. Every send is a call into code the agent's credential cannot modify, running under a role the agent's credential cannot assume, reading policy the agent's credential cannot write.
None of the agent infrastructure is deployed by default. wraps email init deploys none of it; the first wraps email agent create flips a flag in your email config and adds the enforcer, a DynamoDB policy table, a per-agent Lambda alias, and a per-agent IAM user to the same Pulumi stack you already own.
The honest version of that decision: a Wraps-hosted proxy would have been faster to build, and it would have been hotfixable. Customer-side enforcement Lambdas are upgraded by the customer, one customer at a time. A bug in the leash means every customer runs stale enforcement until they run the upgrade.
Our own design doc calls that the strongest argument against the architecture we picked. We picked it anyway, because "your infrastructure, your leash" is the entire product and a proxy would have quietly made Wraps a dependency in your send path. But the tradeoff is real and we're not going to pretend otherwise.
Identity is the alias qualifier
Once the enforcer is in the path, everything depends on one question: when a request arrives, who is asking?
The first implementation answered it the obvious way. The request body carried an agentId, and the handler trusted it. An adversarial review blocked the release over exactly that, and it was right to. Every agent credential could invoke the same function ARN, so the id in the payload was a field the caller chose. Pass a sibling's id and you read that agent's policy, consume that agent's caps, and inherit that agent's allowlist and pinned sender. Kill an agent and it keeps sending by naming a live one. The kill switch was decoration.
The fix is to stop asking the caller. Every agent gets its own Lambda alias named agent-<agentId>, and its IAM policy is pinned to that qualified ARN. AWS then hands the enforcer an identity in a field the caller does not control:
/** Caller identity bound to the invoke principal, never the payload. */
type Caller = { kind: "agent"; agentId: string } | { kind: "platform" };
const AGENT_QUALIFIER_PREFIX = "agent-";
function resolveCaller(context?: { invokedFunctionArn?: string }): Caller {
const arn = context?.invokedFunctionArn ?? "";
const parts = arn.split(":");
const qualifier = parts.length >= 8 ? parts[7] : undefined;
if (qualifier?.startsWith(AGENT_QUALIFIER_PREFIX)) {
return {
kind: "agent",
agentId: qualifier.slice(AGENT_QUALIFIER_PREFIX.length),
};
}
return { kind: "platform" };
}A Lambda ARN is arn:aws:lambda:<region>:<account>:function:<name>[:<qualifier>] — seven colon-separated parts unqualified, eight qualified. So parts[7] exists only on a qualified invoke, and the only qualifier a given credential can produce is the one its IAM policy permits. The agent can rewrite every byte of the payload. It cannot rewrite the ARN it was allowed to invoke.
That distinction is the whole design, so it's worth being concrete about what hangs off it. Every agent-scoped lookup keys on the resolved identity and never on the body: the CONFIG read that carries the kill flag, the policy, and the pinned sender address; the hourly and daily counter keys; and the status lookup, which returns unknown rather than a real verdict when the stored outcome belongs to someone else. Payload-derived identity would have made all three of those attacker-chosen — a privilege-escalation hole reachable by anyone holding any agent credential, including a credential you had already killed.
The unqualified function is a different principal entirely. No qualifier — or $LATEST — resolves to the platform: the Wraps control plane arriving by assume-role to execute an approved send. Authorization is a three-case switch. An agent may send and status. The platform may execute. Anything else comes back as blocked: "unauthorized action for caller" — a disposition, notably, not a thrown error. Both directions are covered by tests, including one that invokes with a forged payload id and asserts the alias identity wins.
One operational consequence, worth knowing before you debug it at 2am: the WRAPS_AGENT_ENFORCER_ARN you give the agent must be the alias ARN. A bare function name or an unqualified ARN invokes $LATEST, gets read as the platform caller, and every send comes back blocked as unauthorized. The identity model makes that misconfiguration fail loudly, which is the correct direction for it to fail.
What this bounds, and what it doesn't
This does not prevent prompt injection.
Nothing here inspects a prompt, a model's output, or intent. An injected agent still forms the malicious send request and still calls the enforcer, and the enforcer will send anything the policy permits. If you allowlist a domain, an injected agent can mail that domain. If your cap is 100 a day, an injected agent has 100 a day.
What changes is the ceiling: one sender address, one recipient per send, an allowlist, an hourly and a daily cap, and a kill switch held by someone who is not the agent. The injected send lands in an approval queue instead of in forty thousand inboxes. That is a blast-radius property, not a safety guarantee, and treating it as the latter is how you end up surprised.
The credential
Each agent gets its own IAM user, wraps-agent-<name>, and one inline policy. This is the entire policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:111122223333:function:wraps-agent-enforcer:agent-<agentId>"
}
]
}One statement, one action, one resource, and the resource is the alias — not the function. What the credential can do: invoke that one alias. What it cannot do: touch SES (there is no ses:* statement at all), invoke any other function, invoke any other alias or version of the same function, invoke the unqualified $LATEST (which would resolve as the platform caller and get rejected anyway), or read a byte of DynamoDB, S3, IAM, or STS. The field's own comment in the source puts it well: a leaked credential can only ever act as its own agent.
Keep two ceilings straight, because they are very different. The agent's policy above is genuinely tight. The enforcer's execution role is not: it holds ses:SendEmail on Resource: "*", matching the breadth of the existing send grant. That is deliberate, and it is precisely why the sender pin exists as code in the next section. The leash is the enforcer's logic, not the enforcer's IAM. A logic bug in the enforcer is the full 3am scenario, and our design docs name it as the confused-deputy risk of this architecture.
The enforcement chain, in order
Every send walks the same five checks, in the same order, before SES is called. The order matters: the cheapest and most terminal checks run first, and nothing consumes a rate-limit slot until it's clear the send is otherwise allowed.
Recipient shape
Before any I/O at all, the enforcer checks that
tois exactly one address. The regex bans whitespace and commas because either one would smuggle a second recipient past a check written for one. A malformed value is blocked outright — no DynamoDB read, no counter write, no webhook.blocked — "invalid recipient"
Kill switch and policy existence
The enforcer reads one DynamoDB item,
CONFIG#<agentId>, written by the Wraps API on every policy change and every kill. A missing item blocks. Akilled: trueitem blocks. A killed agent is stopped locally in the Lambda: no webhook, no queue row, nothing for an operator to approve by mistake.blocked — "killed" or "unknown agent"
Sender pin
The
fromaddress must equal the agent's own address, compared case-insensitively. It fails closed: if the stored address is empty, every send blocks. A spoof attempt is a hard local stop and never reaches the approval queue — there is nothing to approve, because the answer is always no.blocked — "from must be the agent’s own address"
Recipient allowlist
The first check that does not hard-block. The address is matched against the policy's exact-address list and its domain list. A recipient that is off the list is not rejected — it is flagged, which POSTs the pending send to the Wraps API and returns an approval id to the agent. This runs before any cap is consumed.
pending_approval — queued for an operator
Hourly cap, then daily cap
Each window is a conditional
UpdateItem—attribute_not_exists(sends) OR sends < :cap— never a read-modify-write, so the counter cannot exceed the cap under concurrency. A cap of zero or less flags before any counter is touched, so there is never one free send. If the daily cap rejects after the hourly slot was already consumed, the hourly slot is handed back. A DynamoDB error that is not a conditional-check failure is rethrown rather than mislabeled as a cap.pending_approval — "hourly cap reached" / "daily cap reached"
Only after all five does the enforcer call SESv2, with simple content (never raw MIME, so header injection isn't reachable from the agent's credential) and an EmailTags entry carrying the agentId, so every agent send is attributable in your SES event data without any extra plumbing.
The default policy a new agent starts with is 20/hour, 100/day, and an empty allowlist. Which means that out of the box, every recipient is flagged into the approval queue. That surprises people in a demo, and it is intentional: the leash starts tight and you widen it on purpose. If you want an agent that can mail your own domain unattended, you say so.
What the agent sees
On the agent side this is an MCP server. @wraps.dev/mcp switches into enforced mode when both WRAPS_AGENT_ID and WRAPS_AGENT_ENFORCER_ARN are set. Two things change, and both are more interesting than they look.
First, the MCP server's own guardrails switch off. In normal mode it has a write flag, a max-recipients limit, a local recipient allowlist, and a from-override toggle. In enforced mode all of them are skipped deliberately. Enforced mode makes the client dumber on purpose, because a guard running on the machine the agent is driving is a guard inside the blast radius. There is exactly one authority, and it is in your AWS account.
Second — and this is the part that changes how an agent behaves — sent, pending_approval and blocked come back as successful, structured results. Not exceptions. Not isError.
{
"status": "pending_approval",
"approvalId": "3f7c1a9e-0b52-4a1d-9c8e-6d2f0b41a7c3"
}The full disposition union is sent, pending_approval, blocked, failed, unknown. The only things that surface as errors are a missing from, a missing body, more than one recipient, and the enforcer being unreachable — transport and configuration faults. A policy verdict is never an error.
That distinction is load-bearing for an agent's control flow. An error tells a model that something went wrong and implicitly invites it to find another way. A structured result tells it what happened. Handed pending_approval plus an approval id, an agent can report "the send is waiting on a human" and poll check_send_status, a tool that only gets registered in enforced mode. Handed a thrown exception, it would go looking for a workaround — which is the last behavior you want from a possibly-compromised agent standing at a send boundary.
Enforced mode is one recipient per send. That is enforced at the MCP schema (a one-element array or a bare string, nothing else), again by a defensive guard in the handler, and a third time by the enforcer, which re-validates the recipient shape across the repo boundary. The wire types are duplicated by hand into the MCP package with a source-of-truth header; the copy carries only the request and response shapes, not the server-side DynamoDB key builders. There is no CI check for drift between the two — today the protection is a comment, which is worth saying out loud.
When a send is flagged
A flagged send POSTs to the Wraps API, authenticated with the account webhook secret that platform connect already registered. The organization is resolved from the AWS account that matched that secret, never from the request body, so a forged callback cannot inject a row into someone else's org.
The queue has five states and they're all uppercase in Postgres: PENDING → APPROVED → SENT or FAILED, plus REJECTED. Note what isn't there: there is no BLOCKED row and no expiry state. A blocked verdict never becomes a queue row at all — it becomes a notification, because there is nothing for an operator to decide.
Every decision is one atomic SQL statement with the precondition in the WHERE clause:
await dbClient
.update(agentApprovalQueue)
.set({ status, decidedBy, decidedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(agentApprovalQueue.id, approvalId),
eq(agentApprovalQueue.organizationId, organizationId),
eq(agentApprovalQueue.status, "PENDING")
)
)
.returning();Concurrent deciders race in the database rather than in application code. Exactly one gets a row back; the loser gets zero rows, which the route turns into a 409 naming the state it actually observed. The terminal writes — marking a row SENT or FAILED — are guarded on APPROVED the same way. Double-clicking Approve does not double-send.
Kill is terminal, and it's enforced at four layers rather than trusted once. There is a single function that writes the KILLED status and it writes a literal; the general update path strips status from its input at both the type level and at runtime; no route or server action revives an agent; and kill beats approval twice — the API refuses to approve for a killed agent before it even attempts the transition, and the enforcer independently re-checks the kill flag inside execute, because an operator's approval must not outrun a kill. The one thing that is not true: terminality lives in application code. There is no database constraint stopping a raw UPDATE.
Approved sends are at-least-once
We are not going to round this up to exactly-once. When an approval executes, the Lambda sends through SES and then writes the outcome record. SESv2 has no idempotency token, so writing the record first would trade duplicate sends for lost sends, and between those two we chose the failure we can tolerate and explain. A crash in the gap can resend once.
The outcome record itself is an unconditional put with a 48-hour TTL, so it is a replay guard rather than a distributed lock — two genuinely simultaneous executes could both read it empty. What actually prevents that is on the API side: an APPROVED row decided less than fifteen seconds ago is assumed to have a live executor and gets a 409 with no second invoke. Older than that, it's assumed stranded by a crash and the retry is allowed through to heal it, which the outcome record makes safe.
What an operator sees
Two dashboard pages. The agents list shows address, status, and a policy summary that reads like 20/hr · 100/day · 3 allowlisted targets, with a kill button for owners and admins. There is no "create agent" button — creation is CLI-only, and the empty state says so rather than hiding it.
The kill dialog is written to be accurate rather than reassuring: it flips the switch and syncs it to the enforcer, and if that sync fails you'll be told to retry. That isn't hedging. The Postgres write and the DynamoDB write can succeed independently, so the API returns a tri-state sync result and the UI shows an error toast, not a success, when the sync didn't land. A kill that's durable in Wraps and missing in your account is an agent that is still sending, and the only responsible thing to do is say so.
The approval queue lists Send, Reason, Status and Actions, sorted by status priority rather than time so pending work is always on top. Only pending rows get buttons, and the buttons are Approve and Reject — there is no Block, because blocking already happened in your account before the row existed. The page loads once on mount, so an approval that arrives while the tab is open won't appear until you reload it.
What's not in v1
Being explicit about the surface area:
Kill is a soft revoke, not a credential deletion
Killing an agent flips a flag the enforcer reads. The IAM user, its inline policy and its access key all stay live and valid — the credential still successfully invokes the Lambda, and the Lambda refuses every send. No CLI or API path deletes the key. The only true IAM revocation today is wraps email destroy or manual work in the AWS console.
No rotation, and no way to widen the leash from the CLI
There is no rotate subcommand; rotating an agent's access key means replacing the Pulumi resource by hand. And caps and allowlists are set at creation and changed only through the API or the dashboard — the CLI has exactly three agent subcommands and editing policy isn't one of them.
An interrupted create can leave two live access keys
The IAM useris idempotent across re-runs — there's an existence probe that imports it instead of failing. The access key has no such probe. So a create that dies after provisioning IAM but before the deploy outputs are synced back is resumable, and the resumed run mints a fresh key while the previous one is still valid in AWS. Check the console after an interrupted create.
Destroy is all-or-nothing, and it leaves the SES identity
Agent resources live in the same Pulumi email stack, so wraps email destroy does tear down the enforcer, the policy table, the aliases, the IAM users and their keys — and the destroy summary now says so before you confirm, naming the agent mailboxes, the credentials being revoked, and the pending approvals that become undeliverable. Destroy also best-effort marks the stack's agents KILLED in the dashboard, so you're not left staring at agents that look active with no infrastructure behind them. Still true: nothing suppresses or deletes the SES identity for the agent's address, the enforcer's CloudWatch log group survives with no retention set, and there is no "destroy one agent" — removing a single agent means editing config and redeploying, which no command does for you.
Stale enforcers are invisible
This is the flip side of customer-side enforcement. The plan called for stamping an enforcer version into the config item and nagging about it in agent list and the dashboard. That didn't get built. There is no version stamp and no staleness warning, and agent resources don't appear in wraps email status either. If we ship a fix to the leash, you find out from a release note, not from the tool.
The audit trail is incomplete
Six agent audit action types are declared and labeled in the UI. Three are actually written — killed, approved, rejected — and all three come from the dashboard. The API writes none, so a CLI-driven kill produces a notification and no audit row. "Every send attributable to the agent" is the goal; what shipped is SES-level attribution via the agentId email tag plus a partial application-level log.
One recipient per send, and agents don't receive mail yet
Enforced mode sends to exactly one address per call; there is no batch and no bulk path. And while an agent record stores an email address, nothing routes inbound mail to it in v1 — per-agent inbound was explicitly deferred. Today the address is an identity, not an inbox.
The agent CLI command shipped without tests
True at ship, closed three days later. The enforcer Lambda, the repositories, the API routes and the MCP tools all had real behavioral coverage from day one — concurrent approves, forged payload ids, cap compensation, kill races. The 759-line CLI command that provisions all of it went out untested, which was a security-relevant gap in a security-relevant file. A follow-up landed dedicated coverage for it — registration decisions, fatal output guards, save ordering, the webhook-secret hard stop, list and kill.
The SES sandbox is warned about, not handled
agent create prints a warning if your account is in the SES sandbox and then continues. A sandbox account still cannot send to unverified recipients, so a sandboxed agent will be stopped by SES regardless of how permissive its policy is. Get production access first.
Try it
There are three subcommands. That's the whole surface:
Agent Commands:
email agent create Create a leashed agent mailbox
email agent list List agents
email agent kill Kill an agent (revoke sending)Approve and reject are deliberately not here — deciding a queued send is a dashboard and API action, not something an agent host should be able to shell out to.
Two prerequisites, both hard stops in the command rather than surprises later: email has to be initialized on the account, and the account has to be connected to the Wraps platform, because the approval-execute path needs the console access role to invoke the enforcer. Pass the agent name as a positional argument.
# upgrade the CLI
npm i -g @wraps.dev/cli@latest
# create an agent mailbox on a tracked domain
npx @wraps.dev/cli email agent create sdr --domain agents.foo.com
# see what exists
npx @wraps.dev/cli email agent list
# stop one
npx @wraps.dev/cli email agent kill sdrCreation registers the agent with the platform, deploys the enforcer and the scoped credential, persists the stack outputs and syncs the policy — and only then prints the credential. That ordering is deliberate: if the process died after printing, the agent would be unrecoverable. What you get back is the block you paste into your MCP client config:
WRAPS_AGENT_ID=<agentId>
WRAPS_AGENT_ENFORCER_ARN=arn:aws:lambda:us-east-1:111122223333:function:wraps-agent-enforcer:agent-<agentId>
AWS_ACCESS_KEY_ID=<accessKeyId>
AWS_SECRET_ACCESS_KEY=<secretAccessKey>
AWS_REGION=us-east-1The CLI tells you to save the secret because it is shown only once. To be precise about what that means: it is shown once in the terminal. The key does live in your Pulumi state, so anyone who can read that stack can recover it. Treat the state file accordingly.
Continue reading
MCP Server Reference
Enforced mode, the three dispositions, and check_send_status
Agents on Wraps
The leash in product terms: kill switch, sender pin, allowlist, caps
Email Quickstart
Prerequisite: deploy SES to your own AWS account first
Signed Reply-To for Agents
Verified conversation IDs on inbound replies, signed in your AWS account
Give your agent an address, and a leash
The enforcer runs in your AWS account. The agent's credential can invoke one Lambda alias and nothing else.

