AWS Lambda MicroVM Code Agent: Implementation Deep Dive

Cleber Rodrigues
Written by Cleber Rodrigues
AWS Lambda MicroVM Code Agent: Implementation Deep Dive

The aws-lambda-microvm-example repository contains 16 Python modules, four Terraform modules, eight bounded agent tools, and 39 collected tests. On July 29, 2026, I cloned its public main branch and ran the local quality gates. Thirty-eight tests passed in 1.62 seconds. One real-bubblewrap probe was skipped by design because that check runs in the repository’s privileged container smoke test. Ruff found no issues, Terraform 1.15 initialized and validated the AWS provider configuration, and every shell script passed syntax validation.

Those numbers are useful, but they aren’t the reason to study the project. The interesting choice is who gets authority. Claude can decide which Python files to write and when to request a test. It cannot choose a shell command, escape the project path, increase a quota, declare its own work successful, or keep the outer MicroVM alive after the client exits. The service owns those decisions.

This is the implementation companion to the AWS Lambda MicroVMs stateful sandbox guide. That first article explains the new compute primitive, its lifecycle, pricing, networking, and a smaller FastAPI sandbox. Here we’ll stay inside the code. We will trace one JSON brief from the operator’s terminal through a dedicated Firecracker MicroVM, a LangChain tool loop, a networkless pytest process, an authoritative result, and automatic termination.

One boundary up front: this repository calls itself experimental, and that is accurate. I validated its local tests, static checks, Terraform configuration, and packaging logic. I did not create billable Lambda MicroVM or Bedrock resources for this review. The AWS runtime behavior described below follows the implementation and current AWS documentation, not a production load test.

The project in one request

The example turns a coding brief into a disposable, self-testing development session. A caller sends JSON such as this:

{
  "task": "Create a Python package named word_stats with tests.",
  "files": {}
}

The deployment wrapper finds a prebuilt MicroVM image, starts one guest, waits for its RUNNING state, creates a JWE token scoped to port 8080, and posts the brief to /v1/experiments. Inside the guest, a LangChain agent connected to Claude Sonnet 5 on Amazon Bedrock gets tools for reading and writing a confined workspace, running a fixed pytest command, and optionally reading or writing bounded text in one S3 bucket.

The model loops until it stops calling tools or reaches 32 steps. Then the server runs pytest one more time. That second test decides whether the response says passed or failed. The response includes generated files, token usage, step count, captured test output, isolation status, and duration. A shell exit trap terminates the MicroVM regardless of whether the HTTP call succeeded.

AWS Lambda MicroVM code agent implementation architecture

This creates three distinct control planes:

Plane Code that owns it What it controls
Deployment Terraform plus scripts/deploy.sh Buckets, logs, roles, image artifact, image versions, hooks, resources, and network connectors
Agent orchestration runner.py, LangChain, Bedrock Model turns, structured tool calls, token accounting, and the final response message
Untrusted execution sandbox.py plus sandbox_entry.py The exact pytest command, namespaces, environment, UID/GID, capabilities, resource limits, timeout, and output capture

Separating those planes is the project’s strongest design decision. The model is a planner and code author. It is not the security policy engine.

That boundary is intentional and testable.

Read the repository by trust boundary

The source tree is small enough to read in an afternoon, but reading it alphabetically hides the design. Follow the request instead:

HTTP request
  server.py -> application.py -> schemas.py
  service.py -> workspace.py
  runner.py -> model.py -> tools.py
                    |-> sandbox.py -> sandbox_entry.py -> pytest
                    `-> storage.py -> one private S3 bucket

server.py is deliberately boring. It uses Python’s ThreadingHTTPServer, caps the request body before reading it, removes the query string, and passes bytes to a framework-independent Application. The application maps expected failures to stable HTTP codes: validation and workspace failures become 400, a busy guest becomes 409, a missing required sandbox becomes 503, and unexpected exceptions become a sanitized 500.

That adapter split pays off in tests. Application.handle() can be exercised without binding a port, and RequestHandler only needs focused coverage for HTTP-specific behavior such as rejecting a body above 1 MiB. There is no FastAPI or Pydantic dependency in the request path. For an image intended to snapshot quickly, fewer moving pieces are a reasonable choice.

The lifecycle endpoints live in the same application. Paths under /aws/lambda-microvms/runtime/v1/ acknowledge ready, validate, run, resume, suspend, and terminate. The current hooks don’t restore application data or flush a queue; the workload is one-shot, so returning a fast 200 is enough. A stateful IDE would put real work in /run, /suspend, and /resume.

Build a snapshot-safe application image

Lambda MicroVM image creation is not a normal docker push. Lambda downloads a ZIP from S3, executes the included Dockerfile on a managed Amazon Linux 2023 MicroVM base, starts the application, waits for the ready hook, and captures disk and memory. Every future guest restores from that snapshot.

The project’s Dockerfile starts from the AWS MicroVM application base and installs Python 3.12 plus bubblewrap:

ARG BASE_IMAGE=public.ecr.aws/lambda/microvms:al2023-minimal
FROM ${BASE_IMAGE}

ENV PORT=8080 \
    REQUIRE_BWRAP=true \
    PATH="/opt/venv/bin:${PATH}"

RUN dnf install --assumeyes python3.12 python3.12-pip \
      bubblewrap ca-certificates \
    && python3.12 -m venv /opt/venv

COPY pyproject.toml README.md ./
COPY src ./src
RUN python -m pip install --no-cache-dir .

CMD ["python", "-m", "microvm_agent"]

The less visible snapshot rule appears in model.py and storage.py. Both AWS clients are lazy. create_bedrock_model() is passed as a factory and called only after an experiment begins. S3TextStore.client creates Boto3’s S3 client on first tool use. No credential provider, cached session, TLS connection, request identifier, or tenant data is initialized before the ready hook tells Lambda to take the image snapshot.

AWS warns that unique content created during image build will be shared by every MicroVM restored from that version. Lazy runtime initialization prevents the nastiest form of this bug: snapshotting build-role credentials or an authenticated SDK connection and restoring it into every session. It also explains why the image uses AWS’s snapshot-compatible base. TLS libraries and random state must behave correctly after memory restoration.

If you extend the project, keep this rule simple: build immutable code and dependencies before /ready; create credentials, SDK clients, session IDs, and tenant state after /run.

The model gets tools, not a terminal

tools.py creates four workspace tools and four optional S3 tools. The local group contains list_files, read_file, write_file, and run_tests. Persistence adds list_s3_objects, read_s3_text, write_s3_text, and delete_s3_object.

There is no run_shell(command) tool. That omission matters more than the prompt telling Claude not to use a shell. Prompts express intent; exposed capabilities define what the model can actually invoke.

Capability Model can choose Service fixes or rejects
Workspace Relative file name and UTF-8 content Suffix allowlist, traversal checks, symlink checks, file count, per-file bytes, and total bytes
Tests When to request a run python -m pytest -q, plugins, environment, filesystem access, network namespace, limits, and timeout
S3 Safe relative key and bounded text Bucket, IAM scope, encryption header, byte limit, listing cap, and invalid segments
Completion A human-readable summary Pass/fail status from the server’s independent final pytest run

LangChain’s StructuredTool.from_function() derives tool schemas from typed Python functions. Tool exceptions are caught and returned as structured JSON. A bad path or oversized file becomes feedback the model can correct during the next turn instead of crashing the process.

The system prompt still helps. It tells the agent to keep the project modular, add tests, avoid secrets, and use S3 only when the task requires it. But the implementation doesn’t confuse instructions with enforcement. That is the same distinction behind safe command exposure in Bedrock AgentCore shell and session storage: give an agent the smallest useful operation, not an unconstrained interpreter.

Trace the bounded LangChain loop

CodingAgent.run() seeds the workspace, constructs the sandbox and optional S3 store, binds the tool schemas to ChatBedrockConverse, and starts with a system message plus the user’s task. Its control loop is only a few dozen lines:

for loop_step in range(1, settings.max_agent_steps + 1):
    response = model.invoke(messages)
    messages.append(response)
    add_usage(usage, response)

    if not response.tool_calls:
        final_message = message_text(response)
        break

    for call in response.tool_calls:
        tool = agent_tools.by_name.get(call["name"])
        content = invoke_tool(tool, call.get("args", {}))
        messages.append(ToolMessage(
            content=content,
            tool_call_id=call["id"],
        ))

final_test = sandbox.run(workspace.root)

There are four details worth stealing for other agents.

First, the loop has a hard upper bound. The default is 32 model turns, and configuration validation refuses values above 64. A malicious or confused task cannot recurse forever.

Second, token usage is accumulated from every AI message. The API returns input, output, and total tokens. The repository does not calculate dollars because model prices change and inference profiles can route differently, but it preserves the measurements an operator needs for cost allocation.

Third, unknown tool names become an error message instead of dynamic dispatch. The model cannot invent delete_everything and convince Python to find a similarly named function.

Fourth, the final test runs after the model stops. Even if Claude writes “all tests pass” without calling the test tool, the report remains failed when the server-observed exit code is nonzero. This complements trace and rubric evaluation from the Bedrock AgentCore evaluations CI/CD guide. One measures agent behavior and quality; the other enforces a deterministic release fact.

Workspace confinement happens before pytest

The Workspace class is the first containment layer around generated files. It accepts only .py, .txt, .md, .json, .toml, .yaml, and .yml. A raw path is rejected when it is empty, contains a NUL or backslash, is absolute, contains .., exceeds 200 characters, or ends in an unsupported suffix.

After joining the path to the workspace root, the code resolves the parent and verifies that it remains under that root. Existing symlinks and non-regular file targets are rejected. Reads repeat the regular-file and symlink checks. That combination blocks obvious ../../etc/passwd, absolute-path, Windows-separator, and symlink redirection attacks.

Every write calculates UTF-8 byte size, not Python character count. The default envelope is intentionally finite:

Limit Default Valid configuration range
Task length 16,000 characters Fixed by request schema
HTTP body 1 MiB 1 KiB to 4 MiB
Files per workspace 200 1 to 1,000
One file 256 KiB 1 KiB to 1 MiB
Entire workspace 4 MiB 4 KiB to 16 MiB
Captured test output 512 KiB 4 KiB to 2 MiB
Agent turns 32 1 to 64
Model output per turn 16,384 tokens 256 to 16,384

The workspace is created under /tmp/code-agent/run-<random> for each experiment and removed in a finally block. A BoundedSemaphore(1) rejects concurrent work with HTTP 409. This project chooses one active request per MicroVM instead of trying to make one guest a multi-tenant scheduler.

That is the right default. If throughput matters, launch more MicroVMs and let an external control plane route one job to each guest. Multiplexing unrelated tenants inside the same writable workspace would weaken the isolation model the service is supposed to provide.

Bubblewrap creates an inner, networkless test cell

Firecracker isolates the outer guest from other Lambda MicroVMs and the AWS host. It does not make every process inside the guest equally trusted. The parent Python service must reach Bedrock and the dedicated S3 bucket, while generated tests should reach neither.

SandboxRunner solves that with bubblewrap. The command creates new namespaces, binds system directories read-only, mounts the generated project read-only at /workspace, provides empty /tmp, /proc, and /dev, clears the environment, switches to UID and GID 65534, drops all Linux capabilities, and starts sandbox_entry.py:

bwrap --unshare-all --die-with-parent --new-session \
  --ro-bind /usr /usr --ro-bind /opt /opt ... \
  --ro-bind <workspace> /workspace \
  --tmpfs /tmp --proc /proc --dev /dev \
  --chdir /workspace --clearenv \
  --setenv HOME /tmp \
  --setenv PYTHONPATH /workspace \
  --uid 65534 --gid 65534 --cap-drop ALL \
  /opt/venv/bin/python -m microvm_agent.sandbox_entry ...

--unshare-all includes a new network namespace. Generated code cannot reach Bedrock, S3, instance metadata, or the public internet. --clearenv keeps execution-role variables out of the process. The read-only bind prevents a test from modifying the generated source it is supposed to verify.

Inside that namespace, sandbox_entry.py applies RLIMIT_CPU, RLIMIT_AS, RLIMIT_FSIZE, RLIMIT_NOFILE, and RLIMIT_NPROC, requests PR_SET_NO_NEW_PRIVS, and replaces itself with a fixed pytest invocation. The parent also enforces a wall-clock timeout. On expiration, it sends SIGKILL to the entire process group and records timed_out: true.

There is an uncomfortable tradeoff. The MicroVM image requests additionalOsCapabilities = ["ALL"] so the parent service can create the bubblewrap mount and network namespaces. Those capabilities stay inside the Firecracker boundary, and the generated child drops them, but a compromise of the parent service would inherit a broader guest capability set. This is defense in depth, not a proof that arbitrary Python is harmless. The Docker Sandboxes microVM security analysis explores the same outer-VM versus inner-process boundary from a container tooling angle.

Production image promotion should run the repository’s container smoke test after every dependency or base-image update. That test verifies three facts from inside real bubblewrap: no AWS credential variables, a read-only project, and no connection to 1.1.1.1:53. Unit tests that merely inspect command arguments are useful, but a namespace can fail at runtime because of a kernel, capability, or container configuration change.

Persistence is an explicit capability, not a mounted home directory

The MicroVM disappears after each normal request. Sometimes useful output should not. The project creates a second, private S3 bucket and exposes it through four text-only tools.

This is narrower than mounting a general shared filesystem. The model can list up to 1,000 returned objects, read one UTF-8 object, write one UTF-8 object with AES256, or delete one object. Keys must be relative, contain no control characters, avoid empty, . and .. segments, and remain within 1,024 UTF-8 bytes. Object bodies share the 256 KiB file limit by default.

Generated tests never get those tools or the execution-role environment. Only the parent agent loop can invoke S3 operations. That separates two questions that are often blurred together: “May the agent decide to preserve this text?” and “May arbitrary generated code call AWS APIs?” The project answers yes to the first and no to the second.

Terraform enables bucket versioning, S3-managed encryption, ownership enforcement, and all public-access blocks. Noncurrent versions expire after 30 days, while current data has no automatic expiry. Destruction defaults to force_destroy_data = false. The cleanup script checks versions, delete markers, and multipart uploads before it deletes the MicroVM image, stopping early when preserved data exists.

For larger shared workspaces, compare this bounded-object approach with Lambda and S3 Files workspace patterns. The right choice depends on whether the agent needs a few approved artifacts or a filesystem-like collaboration surface.

Terraform separates build, execution, and operator permissions

The infrastructure uses four modules: artifact storage, persistent data storage, observability, and IAM. Terraform owns durable prerequisites. Shell scripts own fast-changing image versions and ephemeral MicroVM sessions.

The IAM module creates three different permission sets:

Identity Allowed work Deliberately absent
Build role Read the exact artifact bucket and write build logs Bedrock invocation, data-bucket access, infrastructure mutation
Execution role Invoke configured Bedrock resources, write runtime logs, manage objects in one data bucket Artifact reads, token creation, image/session administration
Operator policy Upload releases, create/update the named image, run/terminate sessions, create tokens, pass only the two project roles Attachment to a user or role; the project leaves that organization-specific decision to the owner

Inference profiles make Bedrock IAM less obvious. When the configured model ID begins with us., eu., apac., or global., Terraform discovers the inference profile ARN and its backing model ARNs. A direct model ID becomes one foundation-model ARN. Custom application profiles require an explicit list. This avoids a wildcard bedrock:InvokeModel permission while still supporting cross-region system profiles.

The same least-privilege idea applies to agentic infrastructure tools. The Terraform MCP agent workflow recommends separating exploration from production apply. Here the model cannot call Terraform at all; only the external deployer can mutate infrastructure.

Reproducible deployment without host-tool drift

The easiest way to run the project is scripts/deployer.sh. It builds a deployment container pinned to AWS CLI 2.36.1 and Terraform 1.15.8, mounts the repository, forwards standard AWS credential variables, and mounts the host .aws directory read-only. The host needs Docker and working credentials, not the newest MicroVM-aware CLI.

export AWS_REGION=us-east-1
export PROJECT_NAME=microvm-code-agent
export BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-5

./scripts/deployer.sh check
./scripts/deployer.sh deploy
./scripts/deployer.sh run examples/request.json
./scripts/deployer.sh destroy --yes

package.sh builds a ZIP containing only the Dockerfile, package metadata, README, and Python package. deploy.sh calculates its SHA-256 and uploads it to releases/<hash>.zip. The image description includes the first 12 characters of the artifact hash plus a configuration hash derived from environment variables and resource sizing.

If an image with the stable project name exists and its description matches, deployment skips the update. Otherwise, the script calls update-microvm-image; when no image exists, it creates one. Both paths configure ARM64, 4 GiB baseline memory, lifecycle hooks, CloudWatch logging, internet egress for Bedrock, and the capabilities required by bubblewrap.

This hash is not a complete software bill of materials. The Docker build can resolve Python dependencies within the version ranges in pyproject.toml, and the managed base image can advance. For stronger reproducibility, add a lock file, record the exact MicroVM base-image version, emit an SBOM, and sign the artifact manifest. The current hash still solves a practical problem: identical source and settings do not cause an unnecessary image update.

A run owns its cleanup path

run-experiment.sh retrieves Terraform outputs, finds the named image, and calls run-microvm with the execution role, ingress and egress connectors, lifecycle limits, and log destination. Its defaults allow two hours maximum duration, one hour before idle suspension, and 15 suspended minutes before termination.

As soon as the script extracts the MicroVM ID, it installs an exit trap:

terminate_microvm() {
  if [[ "${KEEP_MICROVM:-false}" != "true" && -n "${microvm_id:-}" ]]; then
    aws_cli lambda-microvms terminate-microvm \
      --microvm-identifier "${microvm_id}" >/dev/null 2>&1 || true
  fi
}
trap terminate_microvm EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

The trap covers a failed readiness poll, token error, HTTP failure, broken pipe, jq failure, Ctrl+C, or ordinary completion. KEEP_MICROVM=true is an explicit debugging escape hatch, not the default.

After the guest reaches RUNNING, the script requests an authentication token valid for at most 60 minutes and scoped to port 8080. It sends the token in X-aws-proxy-auth and the port in X-aws-proxy-port. Lambda authenticates the ingress and removes its proxy headers before forwarding the request.

The dedicated endpoint is not a load balancer. If this experiment becomes an API, an external orchestrator must authenticate callers, place jobs, remember the MicroVM ID and deadline, retry idempotently, and reconcile abandoned sessions. The Kubernetes agent sandbox architecture is a useful contrast when you already own a cluster-level control plane and need a managed fleet instead of one shell-driven experiment.

What the tests prove, and what they do not

The 38 locally passing tests cover request validation, HTTP error mapping, configuration bounds, Bedrock model construction, the agent loop, workspace paths and quotas, S3 key and byte limits, lazy clients, server body limits, and sandbox command construction. A fake model test proves the service repeats pytest after the tool loop and builds its report from that observed result.

Ruff and Terraform validation passed against the cloned repository. Shell syntax validation covered deployment, packaging, execution, cleanup, shared helpers, and the deployment-container entrypoint.

The skipped test is worth understanding. test_real_bubblewrap_executes_pytest is enabled only when RUN_BWRAP_TEST=1. The repository’s container smoke script builds the application on python:3.12-slim, runs a privileged test container, and asserts that the generated test sees no AWS variables, cannot write the project, and cannot reach the network. I did not run that privileged Docker check during this article review, so I won’t count it as verified evidence.

The suite also does not prove that the AWS account has Lambda MicroVM quota, that Claude Sonnet 5 Marketplace access is active, that every configured inference-profile ARN is correct for your account, or that the workload survives real adversarial Python. Those require an account-specific integration environment, CloudTrail evidence, cost controls, and security testing.

What I would add before production

This repository is a good blueprint because it keeps its promise small. Turning it into a public coding service would require a different layer around it.

Production concern Current experiment Production addition
Caller identity Operator with AWS credentials Authenticated API, tenant mapping, authorization, and abuse controls
Admission One local JSON file Queue, idempotency key, task policy, per-tenant concurrency and budget
Lifecycle records Shell process plus exit trap Durable session table, leases, deadline scanner, and orphan reconciler
Artifact trust Generated files returned as text Malware scanning, independent CI, review, provenance, and approval-gated publication
Network policy Parent internet egress; child bubblewrap isolation VPC egress connector, explicit destinations, DNS/flow evidence, and recurring isolation canary
Observability CloudWatch logs and JSON token counts Traces per model/tool/test turn, redaction, cost attribution, SLOs, and security alerts
Release integrity Source/config hash Locked dependencies, pinned base version, SBOM, signature, staged image activation, and rollback

I would also separate persistent S3 data by trust domain. The current deployment exposes one data bucket to later agent runs. That is convenient for an experiment but too broad when unrelated customers share a deployment. Per-tenant prefixes with IAM conditions are a minimum; separate execution roles or deployments provide a clearer boundary.

And I would measure the agent, not just the sandbox. Track pass rate on a fixed task set, false-positive tests, average steps, input/output tokens, wall time, MicroVM launch time, test time, and cost per independently accepted result. A fast agent that writes tests tailored to its own bug is not successful.

When this design is the right starting point

Use this project when you want to learn Lambda MicroVM APIs, prototype one isolated coding job per guest, experiment with Bedrock tool calling, or study layered controls around generated Python. It is especially useful for platform engineers because the repository shows the whole boundary: image build, IAM, ingress token, agent loop, inner sandbox, persistence, verification, and cleanup.

Don’t use it unchanged as a public multi-tenant service. Don’t use it for arbitrary languages that need package installation during the job; the child has no network and no shell tool. Don’t use it when a normal Lambda function can handle a stateless event, or when a long-lived service belongs on Fargate, EKS, or EC2.

The previous MicroVM guide gives the broader compute decision. This repository answers the next question: once a dedicated guest exists, how do you keep the model useful without giving it authority over everything inside that guest?

The answer is visible in the code. Give the model typed, bounded operations. Keep credentials and networking in the parent service. Test generated code in a smaller cell. Repeat the acceptance test outside the model loop. Terminate the outer machine from a path the model cannot disable.

That is a credible agent sandbox blueprint. Small enough to read, strict enough to learn from, and honest about what remains experimental.

Project and trusted resources

Cleber Rodrigues

Cleber Rodrigues

AWS Enthusiast | Cloud Architect | AWS Certified Solutions Architect – Professional

Comments

comments powered by Disqus