whim
August 25, 2026 · View on GitHub
Ephemeral AWS Lambda MicroVM shells — a Go library (microvm) and CLI (whim).
Spin up a throwaway root shell in a Firecracker MicroVM in ~2 seconds, run something, and let it disappear.

Recorded against a real AWS account — the account ID is masked in-frame via
WHIM_REDACT_ACCOUNT.whim init(cached), then the hero beat: an interactive root shell runninguname/os-release/whoamiat theλ $prompt,Ctrl-]to disconnect, andimage ls/ps/exec/gcto list, reach into, and reap a live VM.
whim init # one-time: build the default sandbox image (~3 min)
whim # drop into a throwaway root shell
whim run -- pytest # spawn → run → stream output → propagate exit code → terminate
How it works (build-once, run-many)
Lambda MicroVMs run from a prebuilt image, so there's a one-time bootstrap:
whim initprovisions an S3 build bucket + IAM build role, then builds the default image (AL2023 +tar/gzip) and caches its ARN in~/.config/whim/config.json. This takes a couple of minutes.- After that, every
whim/whim runlaunches a fresh VM from that cached image in ~2s, attaches over a WebSocket, and terminates on exit.
Every VM is launched with a server-side TTL (default 25m, max 8h) as a cleanup backstop, so nothing is left running if a client dies.
Requirements
- An AWS account enabled for Lambda MicroVMs, in a supported region (e.g.
us-east-1) - AWS credentials —
aws login,aws-vault, an assumed role, or the environment - Go 1.24+
Install
go install github.com/udgover/whim/cmd/whim@latest # → $(go env GOPATH)/bin
aws login
whim preflight-check # verify creds + permissions
whim init # build the default image
For development, build from a clone instead:
git clone https://github.com/udgover/whim && cd whim
go install ./cmd/whim
CLI
| Command | What it does |
|---|---|
whim / whim shell | Launch a VM and attach an interactive root shell. Ctrl-] disconnects (every other key, incl. Ctrl-C, goes to the remote pty). |
whim run -- <cmd…> | Launch → run → stream combined output → propagate the exit code → terminate. |
whim run -d [--idle <dur>] [--suspend-after <dur>] [--auto-resume] | Launch a persistent box that outlives the process; prints the bare id and returns (see "Persistent boxes" below). |
whim exec <id> -- <cmd…> | Run a command in an existing VM (auto-resumes if suspended). |
whim exec -it <id> | Attach an interactive shell to an existing/suspended VM; Ctrl-] detaches without terminating it. Takes no trailing command. |
whim put <id> <local> <remote-dir> / whim get <id> <remote> <local-dir> | Copy files/dirs in or out (tar.gz, binary-safe, scp -r/docker cp semantics; auto-resumes if suspended). |
whim ps [-q] [--json] | List your whim VMs — running/pending/suspended, state-labeled (like docker ps). |
whim gc [--older-than <dur>] [--yes] | Terminate your whim-owned VMs in bulk (confirms unless --yes). |
whim suspend <id> / whim resume <id> | Pause/restart a VM (disk + memory preserved). |
whim rm <id…> | Terminate VMs by id — like suspend/resume/exec, no ownership check (contrast gc). Idempotent on an already-gone id. |
whim image ls [-q] [--json] / whim image rm <name…> | Manage built images. |
whim build <source> --name <n> [--egress public|none] [`--egress-connector <arn | name>] [--egress-auto-provision] [--force] [--context-subdir
|
whim init [--image-name <n>] [--force] / whim preflight-check / whim version | Bootstrap, checks, version. |
Global flags: --region, --profile. shell/run also take --image <name|arn>
and --ttl <dur>. run/exec/put/get exit 125 for whim-level failures
(distinct from a remote command's own code).
Building custom images (whim build)
whim init builds the default image; whim build builds custom images from
your own source and caches each one by --name (the name is the build/reuse
key) alongside the default in ~/.config/whim/config.json. It reuses the same
bootstrap (artifact bucket, build role, managed base image) as init.
whim build ./app --name whim-app # local build context (Dockerfile at its root)
whim build ./Dockerfile --name whim-min --egress none --egress-connector whim-no-egress
whim build ./app --name whim-isolated --egress none --egress-auto-provision \
--egress-operator-role arn:aws:iam::123456789012:role/whim-network-operator
whim build s3://my-bucket/app.zip --name whim-s3 --json
whim build https://example.com/app.zip --name whim-https
GITHUB_TOKEN=… whim build github.com/org/repo@<full-sha> --name whim-repo --force
Sources: a local directory or Dockerfile, an s3://bucket/key, an
https://host/path (zip archive or raw Dockerfile), or GitHub shorthand
github.com/org/repo@ref / git+https://github.com/org/repo@ref. GitHub
shorthand is CLI-only — it lowers to an HTTPS archive download (no git needed);
the microvm library transports are local, s3://, and https:// only.
-
--nameis required and is the cache key: a secondwhim build --name Xreuses the existing imageXunless you pass--force(which deletes and rebuilds). Name-as-key means a changed source does not rebuild on its own. -
--egress public|nonerecords the image's intended runtime egress policy. AWS associates network connectors atrun-microvmtime, and public internet egress is the service default.publicuses AWS's managedINTERNET_EGRESSconnector.nonemeansNO_PUBLIC_EGRESS— direct public IP and public-hostname HTTPS connections fail — not a nativeNO_EGRESSmode; AWS does not document one, and Whim never claims otherwise (see Security model below for exactly what's tested).--egress nonerequires exactly one of:--egress-auto-provision— Whim creates (or safely reuses) a dedicated VPC, subnet, route table, security group, and Lambda Core connector with no public/default route and zero security-group egress rules. Requires--egress-operator-role: an existing IAM role ARN trustinglambda.amazonaws.comwithAWSLambdaNetworkConnectorOperatorPolicyattached. Whim never creates this role itself — create it once and reuse it. Override naming/CIDRs with--egress-resource-prefix(defaultwhim),--egress-vpc-cidr(default10.242.99.0/24), and--egress-subnet-cidr(default10.242.99.0/25) if the defaults collide with a network you already use.--egress-connector <arn|name>— point at a connector you already manage. Whim resolves its live subnet/route-table/security-group topology and rejects it unless every route is VPC-local and every attached security group has zero outbound rules.--egress-subnet/--egress-security-groupwith an explicit--egress-connector-name— an advanced escape hatch: Whim creates only the Lambda Core connector, against subnets/security groups you already own, with no VPC creation. Whim verifies that an existing same-named connector matches the supplied subnet/security-group IDs, then applies the same no-public-route/zero-egress validation.--egress-connector-namemust be given explicitly here — its default value is never used silently.
Combining more than one of the above is rejected, as is any flag that belongs to a mode you didn't select (e.g.
--egress-connectorwith--egress-vpc-cidr) — they would otherwise be silently ignored.--egress-strict-dnsis not implemented: DNS resolution is not blocked under--egress none(see Security model).Build-time and runtime egress are deliberately separate. Image creation always uses public egress so the service can pull the container base and run network-dependent build steps. For
--egress none, Whim then launches every VM with the recorded isolated connector as an explicit override, after revalidating its VPC topology. Runtime metadata must report exactly that connector or Whim terminates the unverified VM. The rationale is recorded in ADR-001.For the AWS requirements (operator role, caller permissions, service-linked role) and how to grant them following least privilege, see docs/no-public-egress-setup.md. For a repeatable manual test of every
--egress*switch end-to-end, see docs/no-public-egress-test-protocol.md. -
--context-subdir <p>descends into a subdirectory before locating theDockerfile(also strips a single wrapping top-level dir from forge archives). -
--jsonprints one redacted object{name, arn, source, cached, egress}plusegress_connectorand the validatedegress_resource_grouptopology (vpc_id, subnet/route-table/security-group IDs, andresource_groupfor auto-provisioned resources) for--egress none, and suppresses progress chatter. -
GitHub auth: set
GITHUB_TOKENfor private repos — it is sent only as a request header, never placed in a URL, printed, logged, or written to config. A full commit SHA ref is an immutable source identity; branches/tags are moving.
Source safety & limits. Every source — local, S3, and HTTPS alike — is
validated and normalized before any image build: archives are re-rooted, and
absolute/../duplicate paths and root-escaping symlinks are rejected. Caps are
256 MiB compressed, 1 GiB uncompressed, and 10,000 files. A .dockerignore
at the context root is honored for a documented subset: blank lines, #
comments, ! negation, leading-/ anchoring, trailing-/ directory matches,
and */? single-segment globs. Unsupported constructs (** cross-segment
globs and […] character classes) are rejected rather than mis-applied, so a
wrong ignore rule can't silently ship excluded files.
Not in v0.1: private-ECR base images (the build role's IAM is intentionally
not widened until that path is validated) — whim build builds on the managed
base image, same as init.
Privileged shells (--privileged)
A whim shell is root, but under a restricted Linux capability set — so mount,
network namespaces, unshare, and eBPF are all denied (EPERM) even as uid 0.
--privileged grants the image AWS's additionalOsCapabilities: ["ALL"], which
lifts the effective set to all capabilities. It is opt-in: default images
stay minimal-cap and privilege is never granted implicitly.
whim --privileged # throwaway shell that can mount, netns, run eBPF
whim run --privileged -- mount -t tmpfs none /mnt # one-shot privileged command
whim --privileged launches the well-known whim-privileged image, building
it on first use (~2-3 min, like whim init) and caching its ARN alongside your
other images. It ships the tooling to use the caps — util-linux (mount,
unshare), iproute (ip netns), e2fsprogs — since capabilities unlock
syscalls, not binaries. --privileged and --image are mutually exclusive.
To bake privilege into a custom image instead, use whim build --privileged:
whim build ./app --name whim-app --privileged # your source + ALL capabilities
Capabilities are fixed at build time and inherited by every VM launched from
the image. ALL is the only value AWS supports today. Elevated capabilities are
applied within the VM's isolation boundary — per AWS, they do not affect the
host or other MicroVMs.
Persistent boxes (run -d, exec -it, rm)
whim shell/whim run are disposable — the VM terminates when you disconnect or the
command finishes. whim run -d instead launches a persistent box: a VM that
outlives the process, which you reconnect to later with whim exec -it.
whim run -d --idle 15m --suspend-after 2h # prints the id, returns immediately
whim exec -it microvm-abc123 # attach an interactive shell
# ...work, then Ctrl-] to detach — the box keeps running...
whim exec -it microvm-abc123 # reconnect later: fresh shell, disk intact
whim rm microvm-abc123 # done — terminate it
-d/--detachlaunches without attaching or terminating; the printed id is bare (no timestamp) so it's scriptable:id=$(whim run -d).--idle <dur>/--suspend-after <dur>/--auto-resumeconfigure the box's idle policy (both durations require-d). While suspended you pay storage, not compute;exec/put/get/exec -itall resume it transparently on first use.whim exec -it <id>attaches an interactive shell to a running or suspended box. Unlikedocker exec -it, it takes no trailing command — the shell endpoint always starts a fresh shell, with no way to select a program over that connection; run your command once you're attached instead.whim rm <id…>terminates by id (likesuspend/resume/exec— no ownership check; see Security model below). Idempotent on an already-gone id, and a failure on one id in a list doesn't stop the rest from being attempted.
What persists across a detach/reconnect, and what doesn't:
- ✅ Files on disk, and any background process that's still running.
- ❌ Your terminal state — the open editor, shell history-in-progress, current
directory. Reconnecting always gives you a fresh shell (same reconnect
semantics
whim shellalready has — see v0.1 limitations below). Runtmuxinside the box if you want the session itself, not just the disk, to survive.
The idle policy's real behavior — read this before relying on it for cost control. Both directions were verified live, and one is the opposite of what you'd guess:
- A box you've detached from (no attached shell) with a background job running
does suspend after
--idlewith no traffic — and the job actually freezes, not just billing. It resumes (and the job continues) the next time anything touches it. - A box with an attached shell — even sitting idle at the prompt, sending
nothing — does NOT suspend. Merely holding the connection open counts as
traffic. So
whim exec -it <id>left attached while you walk away keeps the box running and billed until--ttl, detach, orrm—--idleonly protects you once you actually detach (Ctrl-]). - A session's actual lifetime is governed by the VM's own
--ttl(hard cap, ≤8h) and its idle policy — not by any client-side token-refresh mechanism. There isn't one: an established shell connection is unaffected by its underlying auth token expiring in the background.
Library
The microvm package is the product; the CLI is a thin client. It is
credential-injection-only — it never resolves ambient credentials; you pass
an aws.Config you built (so it composes with IRSA, ECS task roles, aws-vault,
assumed roles, etc.).
import "github.com/udgover/whim/microvm"
cfg, _ := config.LoadDefaultConfig(ctx) // caller owns credential resolution
mgr := microvm.NewFromConfig(cfg)
sb, err := mgr.Launch(ctx, imageARN, microvm.WithTTL(25*time.Minute))
if err != nil { /* … */ }
defer sb.Terminate(ctx)
res, _ := sb.Exec(ctx, []string{"sh", "-c", "echo hi"}) // combined output + exit code
sb.Shell(ctx, microvm.ShellIO{In: os.Stdin, Out: os.Stdout})
sb.Put(ctx, "./src", "/opt"); sb.Get(ctx, "/var/log", "./logs")
sb.Suspend(ctx); sb.Resume(ctx)
mgr.List(ctx); mgr.GC(ctx, microvm.GCFilter{OlderThan: time.Hour})
To build a custom image from source, the library takes explicit build inputs — there is no ambient discovery (the CLI owns that). The caller supplies the artifact bucket, base image, and build role; GitHub shorthand and default-bootstrap convenience live in the CLI, not here:
arn, err := mgr.BuildFromSource(ctx, "./app", microvm.BuildFromSourceOptions{
Name: "whim-app", // image name = reuse/cache key (required)
ArtifactBucket: "my-artifact-bucket", // caller-owned; staged context uploads here (required)
BaseImageARN: baseImageARN, // managed/base image to build on (required)
BuildRoleARN: buildRoleARN, // role the build assumes to read the staged artifact (required)
Egress: microvm.EgressPublic, // build-time access for the container base and RUN steps
// Force, ContextSubdir, HTTPSHeaders, MaxCompressedBytes, MaxUncompressedBytes …
})
source resolves to exactly one transport — a local path, s3://bucket/key, or
https://host/path; http://, credential-bearing URL userinfo, and unknown
schemes are rejected.
The isolated runtime connector can be your own connector, or come from
Manager.EnsureNoPublicEgressConnector, which the CLI wraps for
--egress-auto-provision. It discovers-and-validates a Whim-managed
NO_PUBLIC_EGRESS VPC/subnet/route table/security group/connector, reusing
it only after every layer passes validation, and creates one when nothing
safe exists to reuse (never falling back to public egress on failure):
resources, err := mgr.EnsureNoPublicEgressConnector(ctx, microvm.NoPublicEgressSpec{
OperatorRoleARN: operatorRoleARN, // existing role; Whim never creates it (required to create)
VPCCIDRBlock: "10.242.99.0/24", // required to create; no default at the library level
SubnetCIDRBlock: "10.242.99.0/25",
})
// resources.ConnectorARN, .VPCID, .SubnetIDs, .RouteTableID, .SecurityGroupID
sb, err := mgr.Launch(ctx, arn,
microvm.WithEgressConnector(microvm.EgressNone, resources.ConnectorARN),
microvm.WithExpectedNoPublicEgress(*resources),
)
For a caller-managed connector, use ValidateNoPublicEgressConnector before
launch and pass its result through the same two launch options. The first sets
the exact runtime connector; the second revalidates its recorded live topology.
Errors are typed sentinels (match with errors.Is): ErrInvalidOption,
ErrInvalidSource, ErrSourceTooLarge, ErrImageNotFound,
ErrVMProvisionFailed, ErrImageBuildFailed, ErrConnClosed, ErrTimeout,
ErrTerminated. EnsureNoPublicEgressConnector additionally wraps
ErrConnectorMissing, ErrConnectorNotActive, ErrResourceNotOwned,
ErrPublicRoute, ErrSecurityGroupEgress, ErrTopologyMismatch, and
ErrOperatorRoleInvalid in a *NoPublicEgressViolation (or
*PartialNoPublicEgressCreationError for a mid-creation failure) — see
Troubleshooting above for what each means. For tests, inject a mock via
NewWithAPI.
Security model
The VM runs your code as root, reachable only through an authenticated WebSocket. whim's guarantees:
--egress noneisNO_PUBLIC_EGRESS, live-tested, not just documented. A MicroVM launched with a recorded--egress nonepolicy cannot reach a direct public IP or a public hostname over HTTPS: both time out at TCP connect, verified by the gatedTestIntegration_NoPublicEgressflow against a real Whim-managed connector. DNS resolution is not blocked:getent hosts/nslookupinside the VM still resolves public hostnames to real IPs; only the resulting connection fails. Security groups and network ACLs cannot filter traffic to AWS's own DNS resolver, so this isNO_PUBLIC_EGRESS, never claimed as AWS's undocumented (and unsupported)NO_EGRESS, and never silently claimed as blocking DNS. MicroVM metadata for a--egress nonelaunch always reports the customer-managed connector, neverINTERNET_EGRESS.- Shell tokens are never logged (the
X-aws-proxy-authvalue lives only in the request header). - Injection-only credentials —
microvmnever sources ambient credentials. - Always a TTL — no VM is launched without one (≤ 8h).
- Privilege is opt-in — default images run with a restricted capability set;
--privileged(all OS capabilities) must be asked for explicitly, and its reach is confined to the VM's isolation boundary (host and other MicroVMs are unaffected). - Ownership-scoped cleanup —
ps/gconly ever touch VMs launched from your own account's microvm-images; AWS-managed base images and other accounts' VMs are never listed or reaped. (Microvms can't be tagged, so ownership is derived from the image ARN — see the caveat below.)gcconfirms unless--yes. - Path-traversal & symlink guards on
get— a compromised VM cannot write outside the destination via a crafted tar (absolute/../escaping-symlink entries are rejected; setuid/setgid bits are stripped). - Remote paths are shell-quoted (no command injection via filenames).
Ownership model: only the heuristic bulk commands ps/gc are scoped to
whim-owned VMs. The id-targeted commands (exec, put, get, suspend,
resume, rm) act on any VM in your account that you explicitly name —
like ssh <host>, naming the target is the authorization. In a shared account,
an explicit whim rm <foreign-id> could terminate someone else's workload.
Troubleshooting --egress-auto-provision
--egress-auto-provision fails closed rather than silently falling back to
public egress or "fixing" a resource it doesn't fully trust. The error names
which check failed:
no-public-egress connector not found— nothing exists yet under the expected connector name. This is the normal trigger for creation, not an error unless creation itself then also fails.... exists but is not ACTIVE— a same-named connector exists but isPENDING(Whim polls this toACTIVEautomatically and proceeds), orFAILED/INACTIVE/DELETING/DELETE_FAILED(Whim does not retry these — inspect it withaws lambda-core get-network-connector --identifier <name>and delete/recreate it manually).resource is not tagged as Whim-managed— a resource matching the expected name/ID exists but lacks Whim'sManagedBy=whimtag. Whim never reuses a resource it doesn't recognize as its own, even if its shape looks safe.resource topology does not match expected no-public-egress shape— drift: a connector, route table, or NACL exists but is associated with different subnets/security groups than Whim expects. Whim never repairs this automatically or creates a second resource group under the same name.route table has a public or default egress route/security group has an outbound rule— the managed route table or security group has drifted from the no-public-egress shape (a public/default route, or any egress rule at all — even a narrow one). Whim fails closed rather than treating a partially-open network as isolated.operator role is not usable by Lambda Core network connectors— your--egress-operator-roleeither doesn't exist, doesn't trustlambda.amazonaws.com, or has no attached/inline policies at all. This check cannot fully verify the role's permissions (that needsiam:GetPolicyVersion/GetRolePolicy, which Whim doesn't call), so a role that passes it can still fail at connector creation with an AWSAccessDeniedif its policy doesn't actually grantec2:CreateNetworkInterface. Attach the AWS-managedAWSLambdaNetworkConnectorOperatorPolicyfor the minimum permissions.- Partial creation failure — if creation fails partway (e.g. the VPC and
subnet were created but security-group creation then failed), the error
names every resource ID created so far. Whim never rolls these back
automatically — find and delete them manually; there is no
whim egress rmyet.
Whim never creates the IAM operator role itself: create it once (trust
lambda.amazonaws.com, attach AWSLambdaNetworkConnectorOperatorPolicy) and
reuse its ARN via --egress-operator-role for every --egress-auto-provision
build.
v0.1 limitations
- Ownership is image-derived, not tagged. Lambda MicroVMs can't be tagged, so
ps/gctreat any VM launched from one of your account's microvm-images as whim-owned. In the rare case you run another tool that also launches from microvm-images, scopegccarefully. - Exec output is combined stdout+stderr (a pty merges them); separate streams would need a guest agent.
- Transfers are in-memory, capped at 256 MiB per
put/get. - Reconnect yields a new shell (disk persists, in-memory shell state does not).
- Terminal resize isn't forwarded yet (full-screen apps use the default size).
- Strict DNS is not implemented (#7).
--egress none(with or without--egress-auto-provision) never blocks DNS resolution;--egress-strict-dnsis rejected until this is built and live-verified. - Whim never creates the IAM operator role.
--egress-auto-provisionrequires an existing--egress-operator-role; Whim only validates it (trust principal, presence of a policy), never callsiam:CreateRole. - No cleanup command for auto-provisioned resources yet.
whim image rmnever deletes the VPC/subnet/route table/security group/connector an--egress-auto-provisionbuild created — delete them manually (see Troubleshooting above) until a dedicated command exists.
Tests
Unit tests are hermetic (no AWS, no network):
go test ./...
Live AWS integration tests are build-tagged and opt-in — they provision and
build real images using your whim init artifact bucket and build role:
WHIM_INTEGRATION=1 go test -tags=integration ./...
The local-directory build runs as-is. Set WHIM_TEST_EGRESS_CONNECTOR to an
isolated Lambda Core VPC connector ARN to run the public-build/isolated-runtime
integration test. Set WHIM_TEST_EGRESS_OPERATOR_ROLE to an IAM role ARN
(trusting lambda.amazonaws.com, AWSLambdaNetworkConnectorOperatorPolicy
attached) to run TestIntegration_NoPublicEgress, which provisions/reuses a real
no-public-egress resource group via EnsureNoPublicEgressConnector, asserts
MicroVM metadata never reports INTERNET_EGRESS, and asserts a direct public
IP and an HTTPS public hostname both fail to connect. The same steps are in
docs/no-public-egress-test-protocol.md.
Set WHIM_TEST_HTTPS_ZIP (an https:// zip whose Dockerfile is at the
root) and WHIM_TEST_GITHUB=org/repo@<full-sha> (plus GITHUB_TOKEN for
private repos) to exercise the remote-source paths. Override the derived inputs
with WHIM_TEST_ARTIFACT_BUCKET / WHIM_TEST_BUILD_ROLE /
WHIM_TEST_BASE_IMAGE.
License
Apache 2.0