Securing
Autonomous
AI Agents.
The new attack surface (almost) no one is ready for.
View the talkThe new attack surface (almost) no one is ready for.
View the talkOld AppSec principles on a new substrate. Each control reduces what an agent can do when something goes wrong.
Stop handing agents an identity that carries the union of every permission any task might ever need. Instead, expose a task-scoped interface: a class or wrapper that only surfaces the methods the current job requires. The agent can't route around what it can't see. Here's an email drafting agent. It gets one thread, one draft method, and nothing else.
// Bad: the agent gets the full Gmail SDK.
// It can read all mail, delete threads, manage labels, send anything.
const agent = createAgent({ tools: [gmailMCPServer] })
// Good: a task-scoped wrapper. No send(). No delete(). No listAll().
class EmailDraftAgent {
constructor(private readonly threadId: string) {}
// One thread. Read only.
async readThread(): Promise<EmailThread> {
return gmail.threads.get({ id: this.threadId })
}
// One draft. In this thread. That's it.
async saveDraft(body: string): Promise<Draft> {
return gmail.drafts.create({ threadId: this.threadId, body })
}
}
// Build tool definitions from the scoped interface only.
const tools = buildTools(new EmailDraftAgent(threadId))
const agent = createAgent({ tools, systemPrompt })
Your agent should not inherit your access. It should inherit your intent.
Agents that execute code run in ephemeral, network-restricted, filesystem-restricted containers. No ambient credentials. No egress except what you explicitly allowlist. This applies especially to MCP servers: every server you install gets the same trust surface as the calling agent. Run them in a jail so the worst-case outcome is a corrupted scratchpad, not a compromised host.
// MCP server running with host-level trust. Don't do this.
const mcp = spawnMCPServer('npx @acme/filesystem-mcp')
// That package now has your ~/.aws, your ~/.ssh, your env vars.
// Sandbox it instead.
const server = await Sandbox.create('acme/filesystem-mcp', {
network: 'none', // no egress
readonlyRootfs: true, // no writes outside mounts
user: 'nobody', // no ambient uid privileges
mounts: [
// Only the workspace. Nothing else is visible.
{ host: workspaceDir, container: '/workspace', readonly: false },
],
// Scoped credentials only. No inherited host environment.
env: { WORKSPACE_TOKEN: scopedToken },
})
const mcp = connectMCP(server.socketPath())
If the worst it can do is corrupt its own scratchpad, you've won.
Not every action needs approval. Confirming everything breaks autonomy and trains users to click through anything. Build a curated list: production writes, payment authorization, external communication, schema changes, IAM mutations. Anything you can't undo with a revert. The gate lives in code. The agent never sees the raw action, only the wrapped tool that requires a human confirmation step. It can't route around what isn't there.
// A generic gate wrapper. Takes any tool, returns a gated version.
function gated<I, O>(
tool: (input: I) => Promise<O>,
label: string,
): (input: I) => Promise<O> {
return async (input) => {
// Blocks until a human approves or rejects in the UI.
await approvals.request({ label, preview: JSON.stringify(input) })
return tool(input)
}
}
const tools = {
readEmail: gmail.read, // autonomous
saveDraft: gmail.drafts.create, // autonomous
sendEmail: gated(gmail.send, 'send email'), // gated
deleteEmail: gated(gmail.delete, 'delete email'), // gated
addLabel: gated(gmail.addLabel, 'modify labels'), // gated
}
Reversible is autonomous. Irreversible is consensual.
Agents will explain what they did. That explanation is generated text. The behavior is tool calls. These will not always match. Sometimes by accident. Sometimes because the agent was instructed to misrepresent its reasoning. Either way, the explanation is theater. Log every tool invocation at the framework layer so nothing goes unrecorded, and apply the same observability discipline you brought to your API server in 2014.
// Wrap the tool layer. The agent's narrative is not the record.
function auditedTools(tools: ToolMap, ctx: TraceCtx): ToolMap {
return Object.fromEntries(
Object.entries(tools).map(([name, fn]) => [
name,
async (input: unknown) => {
const output = await fn(input)
// Immutable append-only record. Not a log you can edit.
await trace.record({
agentId: ctx.agentId,
tool: name,
input,
output,
traceId: ctx.traceId, // link to parent span
ts: Date.now(),
})
return output
},
])
)
}
// The explanation is a story. The trace is the evidence.
The explanation is a story. The trace is the evidence.
Every agent is a principal in your IAM model. Not a service account someone shares across workflows. Not the user's session token passed through. A scoped, attested, revocable identity per agent role, issued per invocation. You already know how to answer the hard questions for humans and services. Apply the same model here: who can spawn agents, what scopes they can inherit, whether scopes are revoked when the user offboards.
// Bad: every agent shares one identity. One compromise, all agents.
const agent = createAgent({ apiKey: process.env.SERVICE_API_KEY })
// Good: scoped, time-bound, bound to this exact invocation.
const token = await iam.issue({
principal: 'agent:email-drafter',
scopes: [
'gmail:threads:read',
'gmail:drafts:create',
],
ttl: '15m',
claims: { userId, sessionId, taskId }, // bound to this task
})
const agent = createAgent({ token })
// Expires in 15 min. Scoped to one user, one session, one task.
// Revoke the token and the agent goes dark.
// User offboards: their agent tokens go with them.
Treat your agent like an intern with root. Onboard it. Scope it. Audit it. Offboard it.