Securing
Autonomous
AI Agents.

The new attack surface (almost) no one is ready for.

View the talk
Five controls. One sprint each.

Blast-radius controls

Old AppSec principles on a new substrate. Each control reduces what an agent can do when something goes wrong.

01

Capability scoping

Least privilege per invocation, not per agent.

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.

02

Sandboxed execution

Default-deny everything outside the sandbox.

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.

03

Human-in-the-loop gating

Curate irreversible actions. Gate them in code, not policy.

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.

04

Behavioral audit

Trust syscalls, not narratives.

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.

05

Agent identity

First-class principal. Not a shared service account.

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.