import { ConfigService } from "@nestjs/config"; import { Logger } from "@nestjs/common"; import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { DockerSandboxService, DEFAULT_ENV_WHITELIST, DEFAULT_SECURITY_OPTIONS, DOCKER_IMAGE_TAG_PATTERN, MAX_IMAGE_TAG_LENGTH, } from "./docker-sandbox.service"; import { DockerSecurityOptions, LinuxCapability } from "./types/docker-sandbox.types"; import Docker from "dockerode"; describe("DockerSandboxService", () => { let service: DockerSandboxService; let mockConfigService: ConfigService; let mockDocker: Docker; let mockContainer: Docker.Container; beforeEach(() => { // Create mock Docker container mockContainer = { id: "container-123", start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), remove: vi.fn().mockResolvedValue(undefined), inspect: vi.fn().mockResolvedValue({ State: { Status: "running" }, }), } as unknown as Docker.Container; // Create mock Docker instance mockDocker = { createContainer: vi.fn().mockResolvedValue(mockContainer), getContainer: vi.fn().mockReturnValue(mockContainer), } as unknown as Docker; // Create mock ConfigService mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.docker.socketPath": "/var/run/docker.sock", "orchestrator.sandbox.enabled": true, "orchestrator.sandbox.defaultImage": "node:20-alpine", "orchestrator.sandbox.defaultMemoryMB": 512, "orchestrator.sandbox.defaultCpuLimit": 1.0, "orchestrator.sandbox.networkMode": "bridge", }; return config[key] !== undefined ? config[key] : defaultValue; }), } as unknown as ConfigService; // Create service with mock Docker instance service = new DockerSandboxService(mockConfigService, mockDocker); }); describe("constructor", () => { it("should be defined", () => { expect(service).toBeDefined(); }); it("should use provided Docker instance", () => { expect(service).toBeDefined(); // Service should use the mockDocker instance we provided }); }); describe("createContainer", () => { it("should create a container with default configuration and security hardening", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const result = await service.createContainer(agentId, taskId, workspacePath); expect(result.containerId).toBe("container-123"); expect(result.agentId).toBe(agentId); expect(result.taskId).toBe(taskId); expect(result.createdAt).toBeInstanceOf(Date); expect(mockDocker.createContainer).toHaveBeenCalledWith({ Image: "node:20-alpine", name: expect.stringContaining(`mosaic-agent-${agentId}`), User: "node:node", HostConfig: { Memory: 512 * 1024 * 1024, // 512MB in bytes NanoCpus: 1000000000, // 1.0 CPU NetworkMode: "bridge", Binds: [`${workspacePath}:/workspace`], AutoRemove: false, ReadonlyRootfs: true, // Security hardening: read-only root filesystem PidsLimit: 100, // Security hardening: prevent fork bombs SecurityOpt: ["no-new-privileges:true"], // Security hardening: prevent privilege escalation CapDrop: ["ALL"], // Security hardening: drop all capabilities }, WorkingDir: "/workspace", Env: [`AGENT_ID=${agentId}`, `TASK_ID=${taskId}`], }); }); it("should create a container with custom resource limits", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { memoryMB: 1024, cpuLimit: 2.0, }; await service.createContainer(agentId, taskId, workspacePath, options); expect(mockDocker.createContainer).toHaveBeenCalledWith( expect.objectContaining({ HostConfig: expect.objectContaining({ Memory: 1024 * 1024 * 1024, // 1024MB in bytes NanoCpus: 2000000000, // 2.0 CPU }), }) ); }); it("should create a container with network isolation", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { networkMode: "none" as const, }; await service.createContainer(agentId, taskId, workspacePath, options); expect(mockDocker.createContainer).toHaveBeenCalledWith( expect.objectContaining({ HostConfig: expect.objectContaining({ NetworkMode: "none", }), }) ); }); it("should create a container with whitelisted environment variables", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { env: { NODE_ENV: "production", LOG_LEVEL: "debug", }, }; await service.createContainer(agentId, taskId, workspacePath, options); expect(mockDocker.createContainer).toHaveBeenCalledWith( expect.objectContaining({ Env: expect.arrayContaining([ `AGENT_ID=${agentId}`, `TASK_ID=${taskId}`, "NODE_ENV=production", "LOG_LEVEL=debug", ]), }) ); }); it("should throw error if container creation fails", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; (mockDocker.createContainer as ReturnType).mockRejectedValue( new Error("Docker daemon not available") ); await expect(service.createContainer(agentId, taskId, workspacePath)).rejects.toThrow( "Failed to create container for agent agent-123" ); }); }); describe("startContainer", () => { it("should start a container by ID", async () => { const containerId = "container-123"; await service.startContainer(containerId); expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId); expect(mockContainer.start).toHaveBeenCalled(); }); it("should throw error if container start fails", async () => { const containerId = "container-123"; (mockContainer.start as ReturnType).mockRejectedValue( new Error("Container not found") ); await expect(service.startContainer(containerId)).rejects.toThrow( "Failed to start container container-123" ); }); }); describe("stopContainer", () => { it("should stop a container by ID", async () => { const containerId = "container-123"; await service.stopContainer(containerId); expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId); expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 }); }); it("should stop a container with custom timeout", async () => { const containerId = "container-123"; const timeout = 30; await service.stopContainer(containerId, timeout); expect(mockContainer.stop).toHaveBeenCalledWith({ t: timeout }); }); it("should throw error if container stop fails", async () => { const containerId = "container-123"; (mockContainer.stop as ReturnType).mockRejectedValue( new Error("Container already stopped") ); await expect(service.stopContainer(containerId)).rejects.toThrow( "Failed to stop container container-123" ); }); }); describe("removeContainer", () => { it("should gracefully stop and remove a container by ID", async () => { const containerId = "container-123"; await service.removeContainer(containerId); expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId); expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 }); expect(mockContainer.remove).toHaveBeenCalledWith({ force: false }); }); it("should fall back to force remove when graceful stop fails", async () => { const containerId = "container-123"; (mockContainer.stop as ReturnType).mockRejectedValueOnce( new Error("Container already stopped") ); await service.removeContainer(containerId); expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 }); expect(mockContainer.remove).toHaveBeenCalledWith({ force: true }); }); it("should fall back to force remove when graceful remove fails", async () => { const containerId = "container-123"; (mockContainer.remove as ReturnType) .mockRejectedValueOnce(new Error("Container still running")) .mockResolvedValueOnce(undefined); await service.removeContainer(containerId); expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 }); // First call: graceful remove (force: false) - fails expect(mockContainer.remove).toHaveBeenNthCalledWith(1, { force: false }); // Second call: force remove (force: true) - succeeds expect(mockContainer.remove).toHaveBeenNthCalledWith(2, { force: true }); }); it("should throw error if both graceful and force removal fail", async () => { const containerId = "container-123"; (mockContainer.stop as ReturnType).mockRejectedValueOnce( new Error("Stop failed") ); (mockContainer.remove as ReturnType).mockRejectedValueOnce( new Error("Container not found") ); await expect(service.removeContainer(containerId)).rejects.toThrow( "Failed to remove container container-123" ); }); it("should use configurable graceful stop timeout", async () => { const customConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.docker.socketPath": "/var/run/docker.sock", "orchestrator.sandbox.enabled": true, "orchestrator.sandbox.defaultImage": "node:20-alpine", "orchestrator.sandbox.defaultMemoryMB": 512, "orchestrator.sandbox.defaultCpuLimit": 1.0, "orchestrator.sandbox.networkMode": "bridge", "orchestrator.sandbox.gracefulStopTimeoutSeconds": 30, }; return config[key] !== undefined ? config[key] : defaultValue; }), } as unknown as ConfigService; const customService = new DockerSandboxService(customConfigService, mockDocker); const containerId = "container-123"; await customService.removeContainer(containerId); expect(mockContainer.stop).toHaveBeenCalledWith({ t: 30 }); expect(mockContainer.remove).toHaveBeenCalledWith({ force: false }); }); }); describe("getContainerStatus", () => { it("should return container status", async () => { const containerId = "container-123"; const status = await service.getContainerStatus(containerId); expect(status).toBe("running"); expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId); expect(mockContainer.inspect).toHaveBeenCalled(); }); it("should throw error if container inspect fails", async () => { const containerId = "container-123"; (mockContainer.inspect as ReturnType).mockRejectedValue( new Error("Container not found") ); await expect(service.getContainerStatus(containerId)).rejects.toThrow( "Failed to get container status for container-123" ); }); }); describe("cleanup", () => { it("should stop and remove container gracefully", async () => { const containerId = "container-123"; await service.cleanup(containerId); // cleanup calls stopContainer first, then removeContainer // stopContainer sends stop({ t: 10 }) // removeContainer also tries stop({ t: 10 }) then remove({ force: false }) expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 }); expect(mockContainer.remove).toHaveBeenCalledWith({ force: false }); }); it("should remove container even if initial stop fails", async () => { const containerId = "container-123"; // First stop call (from cleanup's stopContainer) fails // Second stop call (from removeContainer's graceful attempt) also fails (mockContainer.stop as ReturnType).mockRejectedValue( new Error("Container already stopped") ); await service.cleanup(containerId); // removeContainer falls back to force remove after graceful stop fails expect(mockContainer.remove).toHaveBeenCalledWith({ force: true }); }); it("should throw error if both stop and remove fail", async () => { const containerId = "container-123"; (mockContainer.stop as ReturnType).mockRejectedValue( new Error("Container not found") ); (mockContainer.remove as ReturnType).mockRejectedValue( new Error("Container not found") ); await expect(service.cleanup(containerId)).rejects.toThrow( "Failed to cleanup container container-123" ); }); }); describe("isEnabled", () => { it("should return true if sandbox is enabled in config", () => { expect(service.isEnabled()).toBe(true); }); it("should return false if sandbox is disabled in config", () => { const disabledConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.docker.socketPath": "/var/run/docker.sock", "orchestrator.sandbox.enabled": false, "orchestrator.sandbox.defaultImage": "node:20-alpine", "orchestrator.sandbox.defaultMemoryMB": 512, "orchestrator.sandbox.defaultCpuLimit": 1.0, "orchestrator.sandbox.networkMode": "bridge", }; return config[key] !== undefined ? config[key] : defaultValue; }), } as unknown as ConfigService; const disabledService = new DockerSandboxService(disabledConfigService, mockDocker); expect(disabledService.isEnabled()).toBe(false); }); }); describe("security warning", () => { let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); }); afterEach(() => { warnSpy.mockRestore(); }); it("should log security warning when sandbox is disabled", () => { const disabledConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.docker.socketPath": "/var/run/docker.sock", "orchestrator.sandbox.enabled": false, "orchestrator.sandbox.defaultImage": "node:20-alpine", "orchestrator.sandbox.defaultMemoryMB": 512, "orchestrator.sandbox.defaultCpuLimit": 1.0, "orchestrator.sandbox.networkMode": "bridge", }; return config[key] !== undefined ? config[key] : defaultValue; }), } as unknown as ConfigService; new DockerSandboxService(disabledConfigService, mockDocker); expect(warnSpy).toHaveBeenCalledWith( "SECURITY WARNING: Docker sandbox is DISABLED. Agents will run directly on the host without container isolation." ); }); it("should not log security warning when sandbox is enabled", () => { // Use the default mockConfigService which has sandbox enabled new DockerSandboxService(mockConfigService, mockDocker); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("SECURITY WARNING")); }); }); describe("environment variable whitelist", () => { describe("getEnvWhitelist", () => { it("should return default whitelist when no custom whitelist is configured", () => { const whitelist = service.getEnvWhitelist(); expect(whitelist).toEqual(DEFAULT_ENV_WHITELIST); expect(whitelist).toContain("AGENT_ID"); expect(whitelist).toContain("TASK_ID"); expect(whitelist).toContain("NODE_ENV"); expect(whitelist).toContain("LOG_LEVEL"); }); it("should return custom whitelist when configured", () => { const customWhitelist = ["CUSTOM_VAR_1", "CUSTOM_VAR_2"]; const customConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.docker.socketPath": "/var/run/docker.sock", "orchestrator.sandbox.enabled": true, "orchestrator.sandbox.defaultImage": "node:20-alpine", "orchestrator.sandbox.defaultMemoryMB": 512, "orchestrator.sandbox.defaultCpuLimit": 1.0, "orchestrator.sandbox.networkMode": "bridge", "orchestrator.sandbox.envWhitelist": customWhitelist, }; return config[key] !== undefined ? config[key] : defaultValue; }), } as unknown as ConfigService; const customService = new DockerSandboxService(customConfigService, mockDocker); const whitelist = customService.getEnvWhitelist(); expect(whitelist).toEqual(customWhitelist); }); }); describe("filterEnvVars", () => { it("should allow whitelisted environment variables", () => { const envVars = { NODE_ENV: "production", LOG_LEVEL: "debug", TZ: "UTC", }; const result = service.filterEnvVars(envVars); expect(result.allowed).toEqual({ NODE_ENV: "production", LOG_LEVEL: "debug", TZ: "UTC", }); expect(result.filtered).toEqual([]); }); it("should filter non-whitelisted environment variables", () => { const envVars = { NODE_ENV: "production", DATABASE_URL: "postgres://secret@host/db", API_KEY: "sk-secret-key", AWS_SECRET_ACCESS_KEY: "super-secret", }; const result = service.filterEnvVars(envVars); expect(result.allowed).toEqual({ NODE_ENV: "production", }); expect(result.filtered).toContain("DATABASE_URL"); expect(result.filtered).toContain("API_KEY"); expect(result.filtered).toContain("AWS_SECRET_ACCESS_KEY"); expect(result.filtered).toHaveLength(3); }); it("should handle empty env vars object", () => { const result = service.filterEnvVars({}); expect(result.allowed).toEqual({}); expect(result.filtered).toEqual([]); }); it("should handle all vars being filtered", () => { const envVars = { SECRET_KEY: "secret", PASSWORD: "password123", PRIVATE_TOKEN: "token", }; const result = service.filterEnvVars(envVars); expect(result.allowed).toEqual({}); expect(result.filtered).toEqual(["SECRET_KEY", "PASSWORD", "PRIVATE_TOKEN"]); }); }); describe("createContainer with filtering", () => { let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); }); afterEach(() => { warnSpy.mockRestore(); }); it("should filter non-whitelisted vars and only pass allowed vars to container", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { env: { NODE_ENV: "production", DATABASE_URL: "postgres://secret@host/db", LOG_LEVEL: "info", }, }; await service.createContainer(agentId, taskId, workspacePath, options); // Should include whitelisted vars expect(mockDocker.createContainer).toHaveBeenCalledWith( expect.objectContaining({ Env: expect.arrayContaining([ `AGENT_ID=${agentId}`, `TASK_ID=${taskId}`, "NODE_ENV=production", "LOG_LEVEL=info", ]), }) ); // Should NOT include filtered vars const callArgs = (mockDocker.createContainer as ReturnType).mock.calls[0][0]; expect(callArgs.Env).not.toContain("DATABASE_URL=postgres://secret@host/db"); }); it("should log warning when env vars are filtered", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { env: { DATABASE_URL: "postgres://secret@host/db", API_KEY: "sk-secret", }, }; await service.createContainer(agentId, taskId, workspacePath, options); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("SECURITY: Filtered 2 non-whitelisted env var(s)") ); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("DATABASE_URL")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("API_KEY")); }); it("should not log warning when all vars are whitelisted", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { env: { NODE_ENV: "production", LOG_LEVEL: "debug", }, }; await service.createContainer(agentId, taskId, workspacePath, options); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("SECURITY: Filtered")); }); it("should not log warning when no env vars are provided", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; await service.createContainer(agentId, taskId, workspacePath); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("SECURITY: Filtered")); }); }); }); describe("DEFAULT_ENV_WHITELIST", () => { it("should contain essential agent identification vars", () => { expect(DEFAULT_ENV_WHITELIST).toContain("AGENT_ID"); expect(DEFAULT_ENV_WHITELIST).toContain("TASK_ID"); }); it("should contain Node.js runtime vars", () => { expect(DEFAULT_ENV_WHITELIST).toContain("NODE_ENV"); expect(DEFAULT_ENV_WHITELIST).toContain("NODE_OPTIONS"); }); it("should contain logging vars", () => { expect(DEFAULT_ENV_WHITELIST).toContain("LOG_LEVEL"); expect(DEFAULT_ENV_WHITELIST).toContain("DEBUG"); }); it("should contain locale vars", () => { expect(DEFAULT_ENV_WHITELIST).toContain("LANG"); expect(DEFAULT_ENV_WHITELIST).toContain("LC_ALL"); expect(DEFAULT_ENV_WHITELIST).toContain("TZ"); }); it("should contain Mosaic-specific safe vars", () => { expect(DEFAULT_ENV_WHITELIST).toContain("MOSAIC_WORKSPACE_ID"); expect(DEFAULT_ENV_WHITELIST).toContain("MOSAIC_PROJECT_ID"); expect(DEFAULT_ENV_WHITELIST).toContain("MOSAIC_AGENT_TYPE"); }); it("should NOT contain sensitive var patterns", () => { // Verify common sensitive vars are not in the whitelist expect(DEFAULT_ENV_WHITELIST).not.toContain("DATABASE_URL"); expect(DEFAULT_ENV_WHITELIST).not.toContain("API_KEY"); expect(DEFAULT_ENV_WHITELIST).not.toContain("SECRET"); expect(DEFAULT_ENV_WHITELIST).not.toContain("PASSWORD"); expect(DEFAULT_ENV_WHITELIST).not.toContain("AWS_SECRET_ACCESS_KEY"); expect(DEFAULT_ENV_WHITELIST).not.toContain("ANTHROPIC_API_KEY"); }); }); describe("Docker image tag validation", () => { describe("DOCKER_IMAGE_TAG_PATTERN", () => { it("should match simple image names", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test("node")).toBe(true); expect(DOCKER_IMAGE_TAG_PATTERN.test("ubuntu")).toBe(true); expect(DOCKER_IMAGE_TAG_PATTERN.test("alpine")).toBe(true); }); it("should match image names with tags", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test("node:20-alpine")).toBe(true); expect(DOCKER_IMAGE_TAG_PATTERN.test("ubuntu:22.04")).toBe(true); expect(DOCKER_IMAGE_TAG_PATTERN.test("python:3.11-slim")).toBe(true); }); it("should match image names with registry", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test("docker.io/library/node")).toBe(true); expect(DOCKER_IMAGE_TAG_PATTERN.test("ghcr.io/owner/image:latest")).toBe(true); expect(DOCKER_IMAGE_TAG_PATTERN.test("registry.example.com/myapp:v1.0")).toBe(true); }); it("should match image names with sha256 digest", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test("node@sha256:abc123def456")).toBe(true); expect( DOCKER_IMAGE_TAG_PATTERN.test( "ubuntu@sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" ) ).toBe(true); }); it("should reject images with shell metacharacters", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test("node;rm -rf /")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node|cat /etc/passwd")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node&echo pwned")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node$(whoami)")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node`whoami`")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node > /tmp/out")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node < /etc/passwd")).toBe(false); }); it("should reject images with spaces", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test("node 20-alpine")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test(" node")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node ")).toBe(false); }); it("should reject images with newlines", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test("node\n")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("node\rmalicious")).toBe(false); }); it("should reject images starting with non-alphanumeric characters", () => { expect(DOCKER_IMAGE_TAG_PATTERN.test(".node")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("-node")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("/node")).toBe(false); expect(DOCKER_IMAGE_TAG_PATTERN.test("_node")).toBe(false); }); }); describe("MAX_IMAGE_TAG_LENGTH", () => { it("should be 256", () => { expect(MAX_IMAGE_TAG_LENGTH).toBe(256); }); }); describe("validateImageTag", () => { it("should accept valid simple image names", () => { expect(() => service.validateImageTag("node")).not.toThrow(); expect(() => service.validateImageTag("ubuntu")).not.toThrow(); expect(() => service.validateImageTag("node:20-alpine")).not.toThrow(); }); it("should accept valid registry-qualified image names", () => { expect(() => service.validateImageTag("docker.io/library/node:20")).not.toThrow(); expect(() => service.validateImageTag("ghcr.io/owner/image:latest")).not.toThrow(); expect(() => service.validateImageTag("registry.example.com/namespace/image:v1.2.3") ).not.toThrow(); }); it("should accept valid image names with sha256 digest", () => { expect(() => service.validateImageTag("node@sha256:abc123def456")).not.toThrow(); }); it("should reject empty image tags", () => { expect(() => service.validateImageTag("")).toThrow("Docker image tag must not be empty"); }); it("should reject whitespace-only image tags", () => { expect(() => service.validateImageTag(" ")).toThrow("Docker image tag must not be empty"); }); it("should reject image tags exceeding maximum length", () => { const longImage = "a" + "b".repeat(MAX_IMAGE_TAG_LENGTH); expect(() => service.validateImageTag(longImage)).toThrow( "Docker image tag exceeds maximum length" ); }); it("should reject image tags with shell metacharacters", () => { expect(() => service.validateImageTag("node;rm -rf /")).toThrow( "Docker image tag contains invalid characters" ); expect(() => service.validateImageTag("node|cat /etc/passwd")).toThrow( "Docker image tag contains invalid characters" ); expect(() => service.validateImageTag("node&echo pwned")).toThrow( "Docker image tag contains invalid characters" ); expect(() => service.validateImageTag("node$(whoami)")).toThrow( "Docker image tag contains invalid characters" ); expect(() => service.validateImageTag("node`whoami`")).toThrow( "Docker image tag contains invalid characters" ); }); it("should reject image tags with spaces", () => { expect(() => service.validateImageTag("node 20-alpine")).toThrow( "Docker image tag contains invalid characters" ); }); it("should reject image tags starting with non-alphanumeric", () => { expect(() => service.validateImageTag(".hidden")).toThrow( "Docker image tag contains invalid characters" ); expect(() => service.validateImageTag("-hyphen")).toThrow( "Docker image tag contains invalid characters" ); }); }); describe("createContainer with image tag validation", () => { it("should reject container creation with invalid image tag", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { image: "malicious;rm -rf /" }; await expect( service.createContainer(agentId, taskId, workspacePath, options) ).rejects.toThrow("Docker image tag contains invalid characters"); expect(mockDocker.createContainer).not.toHaveBeenCalled(); }); it("should reject container creation with empty image tag", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { image: "" }; await expect( service.createContainer(agentId, taskId, workspacePath, options) ).rejects.toThrow("Docker image tag must not be empty"); expect(mockDocker.createContainer).not.toHaveBeenCalled(); }); it("should allow container creation with valid image tag", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { image: "node:20-alpine" }; await service.createContainer(agentId, taskId, workspacePath, options); expect(mockDocker.createContainer).toHaveBeenCalledWith( expect.objectContaining({ Image: "node:20-alpine", }) ); }); it("should validate default image tag on construction", () => { // Constructor with valid default image should succeed expect(() => new DockerSandboxService(mockConfigService, mockDocker)).not.toThrow(); }); it("should reject construction with invalid default image tag", () => { const badConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.docker.socketPath": "/var/run/docker.sock", "orchestrator.sandbox.enabled": true, "orchestrator.sandbox.defaultImage": "bad image;inject", "orchestrator.sandbox.defaultMemoryMB": 512, "orchestrator.sandbox.defaultCpuLimit": 1.0, "orchestrator.sandbox.networkMode": "bridge", }; return config[key] !== undefined ? config[key] : defaultValue; }), } as unknown as ConfigService; expect(() => new DockerSandboxService(badConfigService, mockDocker)).toThrow( "Docker image tag contains invalid characters" ); }); }); }); describe("security hardening options", () => { describe("DEFAULT_SECURITY_OPTIONS", () => { it("should drop all Linux capabilities by default", () => { expect(DEFAULT_SECURITY_OPTIONS.capDrop).toEqual(["ALL"]); }); it("should not add any capabilities back by default", () => { expect(DEFAULT_SECURITY_OPTIONS.capAdd).toEqual([]); }); it("should enable read-only root filesystem by default", () => { expect(DEFAULT_SECURITY_OPTIONS.readonlyRootfs).toBe(true); }); it("should limit PIDs to 100 by default", () => { expect(DEFAULT_SECURITY_OPTIONS.pidsLimit).toBe(100); }); it("should disable new privileges by default", () => { expect(DEFAULT_SECURITY_OPTIONS.noNewPrivileges).toBe(true); }); }); describe("getSecurityOptions", () => { it("should return default security options when none configured", () => { const options = service.getSecurityOptions(); expect(options.capDrop).toEqual(["ALL"]); expect(options.capAdd).toEqual([]); expect(options.readonlyRootfs).toBe(true); expect(options.pidsLimit).toBe(100); expect(options.noNewPrivileges).toBe(true); }); it("should return custom security options when configured", () => { const customConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.docker.socketPath": "/var/run/docker.sock", "orchestrator.sandbox.enabled": true, "orchestrator.sandbox.defaultImage": "node:20-alpine", "orchestrator.sandbox.defaultMemoryMB": 512, "orchestrator.sandbox.defaultCpuLimit": 1.0, "orchestrator.sandbox.networkMode": "bridge", "orchestrator.sandbox.security.capDrop": ["NET_RAW", "SYS_ADMIN"], "orchestrator.sandbox.security.capAdd": ["CHOWN"], "orchestrator.sandbox.security.readonlyRootfs": false, "orchestrator.sandbox.security.pidsLimit": 200, "orchestrator.sandbox.security.noNewPrivileges": false, }; return config[key] !== undefined ? config[key] : defaultValue; }), } as unknown as ConfigService; const customService = new DockerSandboxService(customConfigService, mockDocker); const options = customService.getSecurityOptions(); expect(options.capDrop).toEqual(["NET_RAW", "SYS_ADMIN"]); expect(options.capAdd).toEqual(["CHOWN"]); expect(options.readonlyRootfs).toBe(false); expect(options.pidsLimit).toBe(200); expect(options.noNewPrivileges).toBe(false); }); }); describe("createContainer with security options", () => { it("should apply CapDrop to container HostConfig", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; await service.createContainer(agentId, taskId, workspacePath); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.CapDrop).toEqual(["ALL"]); }); it("should apply custom CapDrop when specified in options", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { capDrop: ["NET_RAW", "SYS_ADMIN"] as LinuxCapability[], }, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.CapDrop).toEqual(["NET_RAW", "SYS_ADMIN"]); }); it("should apply CapAdd when specified in options", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { capAdd: ["CHOWN", "SETUID"] as LinuxCapability[], }, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.CapAdd).toEqual(["CHOWN", "SETUID"]); }); it("should not include CapAdd when empty", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; await service.createContainer(agentId, taskId, workspacePath); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.CapAdd).toBeUndefined(); }); it("should apply ReadonlyRootfs to container HostConfig", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; await service.createContainer(agentId, taskId, workspacePath); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.ReadonlyRootfs).toBe(true); }); it("should disable ReadonlyRootfs when specified in options", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { readonlyRootfs: false, }, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.ReadonlyRootfs).toBe(false); }); it("should apply PidsLimit to container HostConfig", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; await service.createContainer(agentId, taskId, workspacePath); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.PidsLimit).toBe(100); }); it("should apply custom PidsLimit when specified in options", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { pidsLimit: 50, }, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.PidsLimit).toBe(50); }); it("should not set PidsLimit when set to 0 (unlimited)", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { pidsLimit: 0, }, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.PidsLimit).toBeUndefined(); }); it("should apply no-new-privileges security option", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; await service.createContainer(agentId, taskId, workspacePath); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.SecurityOpt).toContain("no-new-privileges:true"); }); it("should not apply no-new-privileges when disabled in options", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { noNewPrivileges: false, }, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.SecurityOpt).toBeUndefined(); }); it("should merge partial security options with defaults", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { pidsLimit: 200, // Override just this one } as DockerSecurityOptions, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; // Overridden expect(callArgs.HostConfig?.PidsLimit).toBe(200); // Defaults still applied expect(callArgs.HostConfig?.CapDrop).toEqual(["ALL"]); expect(callArgs.HostConfig?.ReadonlyRootfs).toBe(true); expect(callArgs.HostConfig?.SecurityOpt).toContain("no-new-privileges:true"); }); it("should not include CapDrop when empty array specified", async () => { const agentId = "agent-123"; const taskId = "task-456"; const workspacePath = "/workspace/agent-123"; const options = { security: { capDrop: [] as LinuxCapability[], }, }; await service.createContainer(agentId, taskId, workspacePath, options); const callArgs = (mockDocker.createContainer as ReturnType).mock .calls[0][0] as Docker.ContainerCreateOptions; expect(callArgs.HostConfig?.CapDrop).toBeUndefined(); }); }); describe("security hardening logging", () => { let logSpy: ReturnType; beforeEach(() => { logSpy = vi.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); }); afterEach(() => { logSpy.mockRestore(); }); it("should log security hardening configuration on initialization", () => { new DockerSandboxService(mockConfigService, mockDocker); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Security hardening:")); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("capDrop=ALL")); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("readonlyRootfs=true")); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("pidsLimit=100")); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("noNewPrivileges=true")); }); }); }); });