diff --git a/docs/lab-runtime-hardening.md b/docs/lab-runtime-hardening.md index d8e292d86..cb79556e9 100644 --- a/docs/lab-runtime-hardening.md +++ b/docs/lab-runtime-hardening.md @@ -21,6 +21,32 @@ The backend command builder applies these Docker settings by default: The default Linux user is `root` because the upstream `kalilinux/kali-rolling` image does not ship a `kali` account. The container still runs with dropped Linux capabilities, `no-new-privileges`, no Docker socket, and no host path mounts by default. Custom lab images can provide and select an unprivileged user when stronger in-container user separation is needed. +## MicroVM Isolation (Kata Containers) + +The default `container` isolation mode above shares the host kernel with every lab: namespaces, cgroups, and dropped capabilities narrow the attack surface, but a container escape still lands on the host kernel. Lab boundary options include a `microvm` isolation mode that runs the same lab container under a [Kata Containers](https://katacontainers.io/) OCI runtime class, so each lab gets its own guest kernel inside a hardware-virtualized microVM (Firecracker, QEMU, or Cloud Hypervisor). This is additive to, not a replacement for, the capability/network/resource hardening described above. + +### Enabling it + +- Per-lab: pass `isolation: "microvm"` (and optionally `microvmRuntimeClass`) to the lab create/start/restart API. The choice is persisted in lab metadata, so subsequent starts reuse it. +- Host-wide default: set `PROJECT_LAB_ISOLATION_MODE=microvm`, and optionally `PROJECT_LAB_MICROVM_RUNTIME_CLASS` to override the default runtime class. +- Runtime class names map directly to `docker run --runtime ` and must already be registered in the Docker daemon's `runtimes` config (`/etc/docker/daemon.json`) by a Kata Containers install: + - `kata-fc` (default) — Firecracker VMM. Smallest device model and strongest isolation, but only virtio net/block/vsock devices are available to the guest. + - `kata-qemu` — QEMU VMM. Broader device/hardware compatibility if the Kali workspace image needs devices Firecracker does not emulate. + - `kata-clh` — Cloud Hypervisor VMM. A middle ground between the two. + +### Fail-closed behavior + +Because isolation strength is a security property, the runtime never silently downgrades from `microvm` to plain containers. Before starting a lab with `isolation: "microvm"`, it runs `docker info --format '{{json .Runtimes}}'` and refuses to start (throwing `MicrovmRuntimeUnavailableError`) if the requested runtime class is not registered with the Docker daemon. In `dry-run` mode the availability check is included in the planned command list but is never executed or enforced. + +### Host setup + +MicroVM isolation requires a Docker host with: + +- Nested virtualization / bare-metal KVM access (`/dev/kvm` present, `vmx`/`svm` CPU flag set). +- [Kata Containers](https://github.com/kata-containers/kata-containers/blob/main/docs/install/README.md) installed and registered with the Docker daemon under the runtime class names above. + +This is a host/infrastructure prerequisite outside of ExploitHunter.app's process; most local developer machines, CI runners, and constrained sandboxes do not expose `/dev/kvm` and cannot run `microvm` isolation at all. Use `container` isolation (the default) in those environments, and reserve `microvm` for hosts that are provisioned for it. + ## Network Profiles Human profiles: @@ -90,3 +116,4 @@ Network profile changes are bound to durable approvals. The `agentLabCommandTool - DNS resolution for approved targets uses the container's configured DNS resolver. The iptables rules match against resolved IP addresses at connection time, not domain names. This means the hostname-based allowlisting is resolved at the time each connection is made. - The iptables enforcement runs inside the container and can theoretically be modified by a root user inside the container. The `--cap-drop ALL` before adding `NET_ADMIN`/`NET_RAW` means only these specific networking capabilities are available. - For stronger enforcement, pair with a dedicated Docker network that has its own egress firewall, or use a sidecar proxy (Envoy, SOCKS5) in the container network namespace. +- Namespace/cgroup isolation shares the host kernel with the lab workload. For labs where a kernel-level container escape is an unacceptable risk (for example, running untrusted exploit code or malware samples), use `microvm` isolation (see above) so the lab runs in its own guest kernel instead of the host's. diff --git a/src/mastra/config/storage.ts b/src/mastra/config/storage.ts index b2b70b6d8..ee33f97d9 100644 --- a/src/mastra/config/storage.ts +++ b/src/mastra/config/storage.ts @@ -1,3 +1,6 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + import { FilesystemStore, InMemoryStore, MastraCompositeStore } from "@mastra/core/storage"; import nextEnv from "@next/env"; import { ClickhouseStoreVNext } from "@mastra/clickhouse"; @@ -33,9 +36,21 @@ function localPath(uri: string, scheme: "duckdb" | "lance" | "lancedb") { return uri.slice(`${scheme}://`.length) || "."; } +// libSQL's local sqlite backend fails to open a file whose parent directory does not +// exist yet (e.g. a fresh checkout before `.data/` has ever been created); `libsql://` +// remote URLs and `:memory:` are not local files and need no directory. +function ensureLocalSqliteDirectory(uri: string) { + if (!uri.startsWith("file:")) return; + const path = uri.slice("file:".length); + if (!path || path === ":memory:") return; + const dir = dirname(path); + if (dir && dir !== ".") mkdirSync(dir, { recursive: true }); +} + async function createMastraStorageAdapter(config: MastraStorageConfig, id: string): Promise { switch (config.adapter) { case "libsql": + ensureLocalSqliteDirectory(config.uri); return new LibSQLStore({ id, url: config.uri, connectionTimeoutMs: Number.parseInt(process.env.SQLITE_BUSY_TIMEOUT_MS ?? "5000", 10) }); case "postgres": return new PostgresStore({ id, connectionString: config.uri }); case "redis": return new RedisStore({ id, connectionString: config.uri }); diff --git a/src/server/labs/docker-plan.ts b/src/server/labs/docker-plan.ts index 9b7bcd881..b51fe4c1a 100644 --- a/src/server/labs/docker-plan.ts +++ b/src/server/labs/docker-plan.ts @@ -11,7 +11,10 @@ import { LAB_HARDENING_LABELS, LAB_HOME_PATH, LAB_WORKSPACE_PATH, + SAFE_MICROVM_RUNTIME_CLASS, dockerHardeningArgs, + resolveLabIsolationMode, + resolveMicrovmRuntimeClass, } from "./hardening"; import { dockerNetworkArgs, getLabNetworkProfile, buildEgressEnforcementArgs, buildEgressIptablesScript } from "./network-profiles"; import type { DockerCommandSpec, LabContainerIdentity, LabContainerOptions, LabResourceLimits } from "./types"; @@ -137,9 +140,14 @@ export const buildRunLabCommand = (options: LabContainerOptions): DockerCommandS const user = options.user ?? DEFAULT_KALI_LAB_USER; const networkProfile = getLabNetworkProfile(options.boundary, options.networkProfile); const limits = mergeResourceLimits(options); + const isolation = resolveLabIsolationMode(options.isolation); + const microvmRuntimeClass = isolation === "microvm" ? resolveMicrovmRuntimeClass(options.microvmRuntimeClass) : null; assertSafeValue("image ref", image, SAFE_IMAGE_REF); assertSafeValue("user", user, SAFE_USER); + if (microvmRuntimeClass) { + assertSafeValue("microVM runtime class", microvmRuntimeClass, SAFE_MICROVM_RUNTIME_CLASS); + } return { command: "docker", @@ -154,10 +162,13 @@ export const buildRunLabCommand = (options: LabContainerOptions): DockerCommandS user, "--workdir", LAB_WORKSPACE_PATH, + ...(microvmRuntimeClass ? ["--runtime", microvmRuntimeClass] : []), ...labelArgs({ ...identity.labels, "exploit-hunter.network-profile": networkProfile.id, "exploit-hunter.image-ref": image, + "exploit-hunter.isolation": isolation, + ...(microvmRuntimeClass ? { "exploit-hunter.microvm-runtime-class": microvmRuntimeClass } : {}), }), ...dockerHardeningArgs(options.boundary, limits), ...buildEgressEnforcementArgs({ @@ -202,6 +213,19 @@ export const buildStartLabCommands = (options: LabContainerOptions): DockerComma buildRunLabCommand(options), ]; +export const buildMicrovmRuntimeCheckCommand = (options: LabContainerOptions): DockerCommandSpec | null => { + if (resolveLabIsolationMode(options.isolation) !== "microvm") { + return null; + } + const runtimeClass = resolveMicrovmRuntimeClass(options.microvmRuntimeClass); + assertSafeValue("microVM runtime class", runtimeClass, SAFE_MICROVM_RUNTIME_CLASS); + return { + command: "docker", + args: ["info", "--format", "{{json .Runtimes}}"], + reason: `Verify the ${runtimeClass} microVM runtime is registered with the Docker daemon before starting an isolated lab.`, + }; +}; + export const buildStopLabCommand = (options: LabContainerOptions): DockerCommandSpec => ({ command: "docker", args: ["stop", labContainerIdentity(options).containerName], diff --git a/src/server/labs/hardening.ts b/src/server/labs/hardening.ts index 43b29b2e2..70c8f8801 100644 --- a/src/server/labs/hardening.ts +++ b/src/server/labs/hardening.ts @@ -1,4 +1,4 @@ -import type { LabBoundary, LabResourceLimits } from "./types"; +import type { LabBoundary, LabIsolationMode, LabResourceLimits } from "./types"; export const PREVIOUS_KALI_LAB_IMAGE = "exploit-hunter/kali-workspace:latest"; export const DEFAULT_KALI_LAB_IMAGE = "exploit-hunter/kali-workspace:iproute2"; @@ -68,3 +68,23 @@ export const dockerHardeningArgs = (boundary: LabBoundary, limits: LabResourceLi "--label", `exploit-hunter.lab.boundary=${boundary}`, ]; + +// Kata Containers registers hardware-virtualized OCI runtime classes with the Docker +// daemon (`/etc/docker/daemon.json` "runtimes"); each name below selects a different VMM. +// kata-fc (Firecracker) is the strictest microVM boundary but supports only virtio +// net/block/vsock devices; kata-qemu and kata-clh (Cloud Hypervisor) trade some of that +// isolation minimalism for broader device/hardware compatibility with the Kali image. +export const MICROVM_RUNTIME_CLASSES = ["kata-fc", "kata-qemu", "kata-clh"] as const; +export const DEFAULT_LAB_ISOLATION_MODE: LabIsolationMode = "container"; +export const DEFAULT_MICROVM_RUNTIME_CLASS: (typeof MICROVM_RUNTIME_CLASSES)[number] = "kata-fc"; +export const SAFE_MICROVM_RUNTIME_CLASS = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/; + +export const resolveLabIsolationMode = (isolation?: LabIsolationMode): LabIsolationMode => { + if (isolation === "microvm" || isolation === "container") { + return isolation; + } + return process.env.PROJECT_LAB_ISOLATION_MODE === "microvm" ? "microvm" : DEFAULT_LAB_ISOLATION_MODE; +}; + +export const resolveMicrovmRuntimeClass = (runtimeClass?: string): string => + runtimeClass?.trim() || process.env.PROJECT_LAB_MICROVM_RUNTIME_CLASS?.trim() || DEFAULT_MICROVM_RUNTIME_CLASS; diff --git a/src/server/labs/runtime.ts b/src/server/labs/runtime.ts index c98124528..783875e49 100644 --- a/src/server/labs/runtime.ts +++ b/src/server/labs/runtime.ts @@ -8,13 +8,14 @@ import { buildBuildLabImageCommand, buildDestroyLabContainerCommand, buildInspectLabImageCommand, + buildMicrovmRuntimeCheckCommand, buildProvisionLabCommands, buildRunLabCommand, isManagedKaliLabImage, buildStopLabCommand, labContainerIdentity, } from "./docker-plan"; -import { DEFAULT_KALI_LAB_IMAGE } from "./hardening"; +import { DEFAULT_KALI_LAB_IMAGE, resolveMicrovmRuntimeClass } from "./hardening"; import { buildEgressIptablesScript, buildRuntimeFirewallScript } from "./network-profiles"; import type { DockerCommandSpec, @@ -31,6 +32,17 @@ import type { const execFileAsync = promisify(execFile); +export class MicrovmRuntimeUnavailableError extends Error { + constructor(runtimeClass: string, cause?: string) { + super( + `MicroVM isolation was requested but the "${runtimeClass}" Docker runtime is not registered on this host` + + `${cause ? ` (${cause})` : ""}. Install and register Kata Containers (see docs/lab-runtime-hardening.md) ` + + `or set PROJECT_LAB_ISOLATION_MODE=container.`, + ); + this.name = "MicrovmRuntimeUnavailableError"; + } +} + export interface DockerCommandRunner { isAvailable(): Promise; run( @@ -98,6 +110,10 @@ export class ProjectLabRuntime { async start(options: LabContainerOptions): Promise { const mode = await this.resolveMode(); + const microvmCheckCommand = buildMicrovmRuntimeCheckCommand(options); + if (microvmCheckCommand && mode === "docker") { + await this.assertMicrovmRuntimeAvailable(options, microvmCheckCommand); + } const imageCommands = await this.ensureManagedImage(options, mode); const provisionCommands = buildProvisionLabCommands(options); const commands = [...imageCommands, ...provisionCommands]; @@ -105,6 +121,7 @@ export class ProjectLabRuntime { if (mode === "dry-run") { const planned = [ + ...(microvmCheckCommand ? [microvmCheckCommand] : []), ...commands, { command: "docker" as const, @@ -392,6 +409,31 @@ export class ProjectLabRuntime { } } + private async assertMicrovmRuntimeAvailable(options: LabContainerOptions, checkCommand: DockerCommandSpec): Promise { + const runtimeClass = resolveMicrovmRuntimeClass(options.microvmRuntimeClass); + let stdout: string; + try { + stdout = (await this.runner.run(checkCommand, { timeoutMs: 5_000 })).stdout; + } catch (error) { + throw new MicrovmRuntimeUnavailableError(runtimeClass, error instanceof Error ? error.message : String(error)); + } + + let runtimes: unknown; + try { + runtimes = JSON.parse(stdout || "{}"); + } catch { + runtimes = {}; + } + + const isRegistered = + runtimes !== null && typeof runtimes === "object" && !Array.isArray(runtimes) && + Object.prototype.hasOwnProperty.call(runtimes, runtimeClass); + + if (!isRegistered) { + throw new MicrovmRuntimeUnavailableError(runtimeClass); + } + } + private async applyEgressIptables(options: LabContainerOptions, identity: ReturnType): Promise { const script = buildEgressIptablesScript({ profileId: options.networkProfile ?? "offline", diff --git a/src/server/labs/service.ts b/src/server/labs/service.ts index f89bc980e..1e54a9198 100644 --- a/src/server/labs/service.ts +++ b/src/server/labs/service.ts @@ -6,13 +6,19 @@ import type { LabCredentialMount, HumanLabNetworkProfileId, LabFirewallConfigInput, + LabIsolationMode, LabRuntimeResult, NormalizedLabCreateInput, ProjectLabRepository, ProjectLabStatusView, } from "./types"; import { ProjectLabRuntime } from "./runtime"; -import { DEFAULT_KALI_LAB_IMAGE, PREVIOUS_KALI_LAB_IMAGE, UPSTREAM_KALI_LAB_IMAGE } from "./hardening"; +import { + DEFAULT_KALI_LAB_IMAGE, + PREVIOUS_KALI_LAB_IMAGE, + UPSTREAM_KALI_LAB_IMAGE, + resolveLabIsolationMode, +} from "./hardening"; import { buildUfwCommandPlan, normalizeLabFirewallConfig } from "./network-profiles"; const LEGACY_SIMULATED_IMAGE_REF = "exploit-hunter-lab:simulated"; @@ -100,11 +106,15 @@ export class ProjectLabService { } const networkProfile = readHumanNetworkProfile(input.networkProfile ?? lab.metadata.networkProfile); + const isolation = readLabIsolationMode(input.isolation ?? lab.metadata.isolation); + const microvmRuntimeClass = readMicrovmRuntimeClass(input.microvmRuntimeClass ?? lab.metadata.microvmRuntimeClass); const runtimeResult = await this.runtime.start({ projectId, boundary: "human", image: lab.image_ref, networkProfile, + isolation, + microvmRuntimeClass, credentialMounts: readCredentialMounts(input.credentialMounts ?? lab.metadata.credentialMounts), }); const runtimeId = lab.runtime_id ?? runtimeResult.container.containerName; @@ -119,7 +129,7 @@ export class ProjectLabService { started_at: lab.started_at ?? timestamp(), stopped_at: null, destroyed_at: null, - metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile })), + metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile, isolation, microvmRuntimeClass })), }); return this.statusFromLab(projectId, running); @@ -167,11 +177,15 @@ export class ProjectLabService { networkProfile: "offline", }); const networkProfile = readHumanNetworkProfile(input.networkProfile ?? lab.metadata.networkProfile); + const isolation = readLabIsolationMode(input.isolation ?? lab.metadata.isolation); + const microvmRuntimeClass = readMicrovmRuntimeClass(input.microvmRuntimeClass ?? lab.metadata.microvmRuntimeClass); const startResult = await this.runtime.start({ projectId, boundary: "human", image: lab.image_ref, networkProfile, + isolation, + microvmRuntimeClass, credentialMounts: readCredentialMounts(input.credentialMounts ?? lab.metadata.credentialMounts), }); const runtimeResult = mergeRuntimeResults(destroyResult, startResult); @@ -186,7 +200,7 @@ export class ProjectLabService { started_at: timestamp(), stopped_at: null, destroyed_at: null, - metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile })), + metadata: mergeMetadata(lab.metadata, labPreferenceMetadata({ ...input, networkProfile, isolation, microvmRuntimeClass })), }); return this.statusFromLab(projectId, running); @@ -316,9 +330,14 @@ function labDefaults(input: LabCreateInput): NormalizedLabCreateInput { } function labPreferenceMetadata(input: LabCreateInput): JsonObject { + const microvmRuntimeClass = readMicrovmRuntimeClass(input.microvmRuntimeClass); return mergeMetadata( mergeMetadata(sanitizeJsonObject(input.metadata), credentialMountMetadata(input.credentialMounts)), - { networkProfile: readHumanNetworkProfile(input.networkProfile) }, + { + networkProfile: readHumanNetworkProfile(input.networkProfile), + isolation: readLabIsolationMode(input.isolation), + ...(microvmRuntimeClass ? { microvmRuntimeClass } : {}), + }, ); } @@ -328,6 +347,14 @@ function readHumanNetworkProfile(value: unknown): HumanLabNetworkProfileId { : DEFAULT_HUMAN_NETWORK_PROFILE; } +function readLabIsolationMode(value: unknown): LabIsolationMode { + return resolveLabIsolationMode(value === "microvm" || value === "container" ? value : undefined); +} + +function readMicrovmRuntimeClass(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + function readFirewallConfig(value: unknown): LabFirewallConfigInput | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as LabFirewallConfigInput diff --git a/src/server/labs/types.ts b/src/server/labs/types.ts index 2e8fab13f..899132e1e 100644 --- a/src/server/labs/types.ts +++ b/src/server/labs/types.ts @@ -33,6 +33,8 @@ export interface LabCreateInput { networkProfile?: HumanLabNetworkProfileId; firewall?: LabFirewallConfigInput; credentialMounts?: LabCredentialMount[]; + isolation?: LabIsolationMode; + microvmRuntimeClass?: string; metadata?: JsonObject; } @@ -62,6 +64,13 @@ export interface ProjectLabRepository { export type LabBoundary = "human" | "agent"; +/** + * "container" is the default Linux-namespace/cgroup boundary (see hardening.ts). + * "microvm" runs the lab under a hardware-virtualized Kata Containers runtime class + * for kernel-level isolation between the lab workload and the Docker host. + */ +export type LabIsolationMode = "container" | "microvm"; + export type HumanLabNetworkProfileId = "offline" | "package-egress" | "full"; export type AgentLabNetworkProfileId = "offline" | "approved-targets" | "package-egress" | "full"; @@ -107,6 +116,8 @@ export interface LabContainerOptions extends LabProjectRef { networkName?: string; approvedTargets?: string[]; credentialMounts?: LabCredentialMount[]; + isolation?: LabIsolationMode; + microvmRuntimeClass?: string; } export interface DockerCommandSpec { diff --git a/tests/security-chat/lab-runtime.test.ts b/tests/security-chat/lab-runtime.test.ts index aa15178ea..9caaf966c 100644 --- a/tests/security-chat/lab-runtime.test.ts +++ b/tests/security-chat/lab-runtime.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest"; import { AGENT_LAB_NETWORK_PROFILES, DEFAULT_KALI_LAB_IMAGE, + DEFAULT_MICROVM_RUNTIME_CLASS, HUMAN_LAB_PACKAGE_CAPABILITIES, HUMAN_LAB_NETWORK_PROFILES, + MicrovmRuntimeUnavailableError, ProjectLabRuntime, + buildMicrovmRuntimeCheckCommand, buildRuntimeFirewallScript, buildRunLabCommand, buildUfwCommandPlan, @@ -408,3 +411,127 @@ describe("project lab Docker runtime primitives", () => { ]); }); }); + +describe("microVM isolation", () => { + it("stays on the plain container boundary by default, with no --runtime flag or check command", () => { + const command = buildRunLabCommand({ ...humanOptions, networkProfile: "offline" }); + + expect(command.args).not.toEqual(expect.arrayContaining(["--runtime"])); + expect(command.args).toEqual(expect.arrayContaining(["exploit-hunter.isolation=container"])); + expect(buildMicrovmRuntimeCheckCommand({ ...humanOptions })).toBeNull(); + }); + + it("adds a --runtime flag and audit labels when microVM isolation is requested", () => { + const command = buildRunLabCommand({ + ...humanOptions, + networkProfile: "offline", + isolation: "microvm", + }); + + expect(command.args[command.args.indexOf("--runtime") + 1]).toBe(DEFAULT_MICROVM_RUNTIME_CLASS); + expect(command.args).toEqual( + expect.arrayContaining([ + "exploit-hunter.isolation=microvm", + `exploit-hunter.microvm-runtime-class=${DEFAULT_MICROVM_RUNTIME_CLASS}`, + ]), + ); + }); + + it("honors an explicit microVM runtime class override", () => { + const command = buildRunLabCommand({ + ...humanOptions, + networkProfile: "offline", + isolation: "microvm", + microvmRuntimeClass: "kata-qemu", + }); + + expect(command.args[command.args.indexOf("--runtime") + 1]).toBe("kata-qemu"); + }); + + it("rejects unsafe microVM runtime class names", () => { + expect(() => + buildRunLabCommand({ + ...humanOptions, + networkProfile: "offline", + isolation: "microvm", + microvmRuntimeClass: "kata-fc; rm -rf /", + }), + ).toThrow(/Unsafe microVM runtime class/); + }); + + it("plans a Docker daemon runtime-availability check when isolation is microvm in dry-run mode", async () => { + const runner: DockerCommandRunner = { + async isAvailable() { + return false; + }, + async run() { + throw new Error("dry-run must not execute docker"); + }, + }; + const runtime = new ProjectLabRuntime({ mode: "auto", runner }); + + const result = await runtime.start({ + projectId: "microvm-dry-run-project", + boundary: "agent", + networkProfile: "offline", + isolation: "microvm", + }); + + expect(result.mode).toBe("dry-run"); + expect(result.commands[0]?.args).toEqual(["info", "--format", "{{json .Runtimes}}"]); + }); + + it("fails closed when the requested microVM runtime is not registered with the Docker daemon", async () => { + const runner: DockerCommandRunner = { + async isAvailable() { + return true; + }, + async run(command) { + if (command.args[0] === "info") { + return { stdout: JSON.stringify({ runc: {} }), stderr: "", exitCode: 0 }; + } + throw new Error("must not provision a lab when the microVM runtime check fails"); + }, + }; + const runtime = new ProjectLabRuntime({ mode: "docker", runner }); + + await expect( + runtime.start({ + projectId: "microvm-missing-runtime-project", + boundary: "human", + networkProfile: "offline", + isolation: "microvm", + }), + ).rejects.toBeInstanceOf(MicrovmRuntimeUnavailableError); + }); + + it("starts the lab once the requested microVM runtime is registered with the Docker daemon", async () => { + const executed: DockerCommandSpec[] = []; + const runner: DockerCommandRunner = { + async isAvailable() { + return true; + }, + async run(command) { + executed.push(command); + if (command.args[0] === "info") { + return { stdout: JSON.stringify({ "kata-fc": {}, runc: {} }), stderr: "", exitCode: 0 }; + } + if (command.args.includes('{{ index .Config.Labels "exploit-hunter.image-ref" }}')) { + return { stdout: `${DEFAULT_KALI_LAB_IMAGE}\n`, stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }; + const runtime = new ProjectLabRuntime({ mode: "docker", runner }); + + const result = await runtime.start({ + projectId: "microvm-available-project", + boundary: "human", + networkProfile: "offline", + isolation: "microvm", + }); + + expect(result.mode).toBe("docker"); + expect(executed[0]?.args).toEqual(["info", "--format", "{{json .Runtimes}}"]); + }); +});