All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace the always-force container removal (SIGKILL) with a two-phase approach: first attempt graceful stop (SIGTERM with configurable timeout), then remove without force. Falls back to force remove only if the graceful path fails. The graceful stop timeout is configurable via orchestrator.sandbox.gracefulStopTimeoutSeconds (default: 10s). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1166 lines
43 KiB
TypeScript
1166 lines
43 KiB
TypeScript
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<string, unknown> = {
|
|
"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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
|
|
.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<typeof vi.fn>).mockRejectedValueOnce(
|
|
new Error("Stop failed")
|
|
);
|
|
(mockContainer.remove as ReturnType<typeof vi.fn>).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<string, unknown> = {
|
|
"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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockRejectedValue(
|
|
new Error("Container not found")
|
|
);
|
|
(mockContainer.remove as ReturnType<typeof vi.fn>).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<string, unknown> = {
|
|
"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<typeof vi.spyOn>;
|
|
|
|
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<string, unknown> = {
|
|
"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<string, unknown> = {
|
|
"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<typeof vi.spyOn>;
|
|
|
|
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<typeof vi.fn>).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<string, unknown> = {
|
|
"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<string, unknown> = {
|
|
"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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock
|
|
.calls[0][0] as Docker.ContainerCreateOptions;
|
|
expect(callArgs.HostConfig?.CapDrop).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("security hardening logging", () => {
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
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"));
|
|
});
|
|
});
|
|
});
|
|
});
|