diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt new file mode 100644 index 00000000..e5e8084f --- /dev/null +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt @@ -0,0 +1,123 @@ +package org.coralprotocol.coralserver.agent.execution + +import com.github.dockerjava.api.command.CreateContainerCmd +import com.github.dockerjava.api.model.Bind +import com.github.dockerjava.api.model.Capability +import com.github.dockerjava.api.model.HostConfig +import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier +import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier +import org.coralprotocol.coralserver.config.DockerConfig +import org.coralprotocol.coralserver.config.SecurityConfig +import org.coralprotocol.coralserver.logging.LoggingInterface + +data class DockerExecutionTrustPolicy( + val readOnlyRootFilesystem: Boolean = false, + val noNewPrivileges: Boolean = true, + val dropCapabilities: Set = setOf("ALL"), + // bounds fork-bomb blast radius; 256 PIDs covers typical agent process counts + val pidsLimit: Long? = 256, + val nanoCpus: Long? = null, + val memoryLimitBytes: Long? = null, + val user: String? = null, + val tmpFs: Map = emptyMap(), + val requireImageDigest: Boolean = false, +) { + val requiresWritableTmpHome: Boolean + get() = readOnlyRootFilesystem || user != null +} + +data class ExecutionTrustPolicy( + val profileName: String, + val allowExecutableRuntime: Boolean, + val docker: DockerExecutionTrustPolicy, +) + +// Authoritative source → docker hardening profile mapping for Stage 1. Local is trusted; Marketplace and Linked +// share the marketplace profile. Stage 2 will plug in declared-intent and runtime-aware overrides here. +fun AgentRegistrySourceIdentifier.resolveTrustPolicy( + dockerConfig: DockerConfig, + securityConfig: SecurityConfig, +): ExecutionTrustPolicy = when (this) { + is AgentRegistrySourceIdentifier.Local -> ExecutionTrustPolicy( + profileName = "trusted_local", + allowExecutableRuntime = true, + docker = dockerConfig.trusted, + ) + is AgentRegistrySourceIdentifier.Marketplace, + is AgentRegistrySourceIdentifier.Linked -> ExecutionTrustPolicy( + profileName = "marketplace_untrusted", + allowExecutableRuntime = securityConfig.allowUntrustedExecutableRuntime, + docker = dockerConfig.marketplace, + ) +} + +fun DockerExecutionTrustPolicy.buildHostConfig( + volumes: List, + logger: LoggingInterface, +): HostConfig { + val hostConfig = HostConfig() + .withBinds(volumes) + .withPrivileged(false) + .withReadonlyRootfs(readOnlyRootFilesystem) + + if (noNewPrivileges) { + hostConfig.withSecurityOpts(listOf("no-new-privileges")) + } + + if (tmpFs.isNotEmpty()) { + hostConfig.withTmpFs(tmpFs) + } + + if (dropCapabilities.isNotEmpty()) { + hostConfig.withCapDrop(*dropCapabilities.toCapabilities(logger).toTypedArray()) + } + + pidsLimit?.let { hostConfig.withPidsLimit(it) } + nanoCpus?.let { hostConfig.withNanoCPUs(it) } + memoryLimitBytes?.let { hostConfig.withMemory(it) } + + return hostConfig +} + +fun DockerExecutionTrustPolicy.applyTo( + cmd: CreateContainerCmd, + volumes: List, + logger: LoggingInterface, +) { + cmd.withHostConfig(buildHostConfig(volumes, logger)) + user?.takeIf { it.isNotBlank() }?.let { cmd.withUser(it) } +} + +fun DockerExecutionTrustPolicy.sanitizeImage( + imageName: String, + id: RegistryAgentIdentifier, + profileName: String, + logger: LoggingInterface, +): String { + if (imageName.contains("@sha256:")) { + return imageName + } + + if (requireImageDigest) { + throw IllegalArgumentException( + "Docker image $imageName must be pinned by digest (@sha256:...) by execution profile '$profileName'" + ) + } + + if (imageName.contains(":")) { + if (!imageName.endsWith(":${id.version}")) { + logger.warn { "Image $imageName does not match the agent version: ${id.version}" } + } + + return imageName + } + + return "$imageName:${id.version}" +} + +private fun Set.toCapabilities(logger: LoggingInterface): List = + mapNotNull { capability -> + runCatching { enumValueOf(capability.uppercase()) } + .onFailure { logger.warn { "Unknown Docker capability in config: $capability" } } + .getOrNull() + } diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/registry/AgentRegistrySource.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/registry/AgentRegistrySource.kt index 61bba960..5938f4ec 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/registry/AgentRegistrySource.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/registry/AgentRegistrySource.kt @@ -2,6 +2,7 @@ package org.coralprotocol.coralserver.agent.registry +import io.github.smiley4.schemakenerator.core.annotations.Description import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,8 +12,15 @@ import org.coralprotocol.coralserver.util.utcTimeNow import org.koin.core.component.KoinComponent import kotlin.time.ExperimentalTime +/** + * Identifies where an agent's registry entry came from. The runtime trust tier applied to the agent is a function + * of this value: `Local` is treated as trusted; `Marketplace` and `Linked` are treated as untrusted and run under the + * marketplace docker hardening profile. The authoritative mapping lives in + * `org.coralprotocol.coralserver.agent.execution.resolveTrustPolicy`. + */ @Serializable @JsonClassDiscriminator("type") +@Description("Where an agent's registry entry came from. Local is trusted; Marketplace and Linked are untrusted (run under the marketplace docker hardening profile).") sealed class AgentRegistrySourceIdentifier { @Serializable @SerialName("local") diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/DockerRuntime.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/DockerRuntime.kt index 13f27973..f5dd219d 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/DockerRuntime.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/DockerRuntime.kt @@ -11,7 +11,8 @@ import io.ktor.utils.io.* import kotlinx.coroutines.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier +import org.coralprotocol.coralserver.agent.execution.applyTo +import org.coralprotocol.coralserver.agent.execution.sanitizeImage import org.coralprotocol.coralserver.events.SessionEvent import org.coralprotocol.coralserver.logging.LoggingInterface import org.coralprotocol.coralserver.logging.LoggingTag @@ -41,8 +42,12 @@ data class DockerRuntime( } val docker = applicationRuntimeContext.dockerClient - val sanitisedImageName = - docker.sanitizeDockerImageName(image, executionContext.registryAgent.identifier, executionContext.logger) + val sanitisedImageName = executionContext.executionPolicy.docker.sanitizeImage( + imageName = image, + id = executionContext.registryAgent.identifier, + profileName = executionContext.executionPolicy.profileName, + logger = executionContext.logger, + ) var containerId: String? = null try { @@ -71,12 +76,17 @@ data class DockerRuntime( val containerCreationCmd = docker.createContainerCmd(sanitisedImageName) .withName(executionContext.agent.secret) .withEnv(environment.map { (key, value) -> "$key=$value" }) - .withHostConfig(HostConfig().withBinds(volumes)) .withAttachStdout(true) .withAttachStderr(true) .withStopTimeout(1) .withAttachStdin(false) // Stdin makes no sense with orchestration + executionContext.executionPolicy.docker.applyTo( + cmd = containerCreationCmd, + volumes = volumes, + logger = executionContext.logger, + ) + if (command != null) containerCreationCmd.withCmd(*command.toTypedArray()) @@ -116,6 +126,11 @@ data class DockerRuntime( @OptIn(InternalAPI::class) if (e.rootCause is InterruptedException) throw CancellationException("Docker timeout", e) + + executionContext.logger.error(e) { + "Docker client error for agent ${executionContext.agent.name} (image=$sanitisedImageName, container=${containerId ?: "none"})" + } + throw e } finally { withContext(NonCancellable) { when (val containerId = containerId) { @@ -135,22 +150,6 @@ data class DockerRuntime( } } -private fun DockerClient.sanitizeDockerImageName( - imageName: String, - id: RegistryAgentIdentifier, - logger: LoggingInterface -): String { - if (imageName.contains(":")) { - if (!imageName.endsWith(":${id.version}")) { - logger.warn { "Image $imageName does not match the agent version: ${id.version}" } - } - - return imageName - } else { - return "$imageName:${id.version}" - } -} - private fun DockerClient.findImage(imageName: String): Image? = listImagesCmd() .exec() diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/ExecutableRuntime.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/ExecutableRuntime.kt index 9ed1d8cc..a89af101 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/ExecutableRuntime.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/ExecutableRuntime.kt @@ -25,6 +25,13 @@ data class ExecutableRuntime( executionContext: SessionAgentExecutionContext, applicationRuntimeContext: ApplicationRuntimeContext ) { + if (!executionContext.executionPolicy.allowExecutableRuntime) { + val message = + "Executable runtime is disabled by execution profile '${executionContext.executionPolicy.profileName}'" + executionContext.logger.error { message } + throw IllegalStateException(message) + } + val potentialPaths = buildList { // on Windows, if given a path without an extension, try .exe, .cmd and .bat files // on Linux it is expected that a marks files as executables and uses the appropriate shebang to achieve diff --git a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt index 6f5fbc5d..a6ba63e3 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt @@ -1,8 +1,6 @@ package org.coralprotocol.coralserver.config -import com.sksamuel.hoplite.ConfigAlias -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import org.coralprotocol.coralserver.agent.execution.DockerExecutionTrustPolicy import org.coralprotocol.coralserver.util.isWindows import java.io.File @@ -49,6 +47,13 @@ private fun defaultDockerSocket(): String { } } +// 64 MiB tmpfs scratch for the agent's writable temp dir. Flags block privilege-escalation via the writable +// mount (noexec, nosuid, nodev); size is small enough to bound abuse but large enough for typical caches, +// sockets, and small file-transport option payloads. +private fun defaultDockerTmpFs(): Map = mapOf( + "/tmp" to "rw,noexec,nosuid,nodev,size=64m" +) + data class DockerConfig( /** * Optional docker socket path @@ -97,5 +102,26 @@ data class DockerConfig( * * @see [containerPathSeparator] */ - val containerTemporaryDirectory: String = "/tmp" -) \ No newline at end of file + val containerTemporaryDirectory: String = "/tmp", + + /** + * Container execution profile for locally-registered (trusted) agents. Permissive by default — the operator + * ships and runs their own code here, so only the tmpfs scratch is locked down. + */ + val trusted: DockerExecutionTrustPolicy = DockerExecutionTrustPolicy( + tmpFs = defaultDockerTmpFs(), + ), + + /** + * Container execution profile for marketplace and linked-source (untrusted) agents. Read-only rootfs, + * distroless 'nonroot' UID, ~1 vCPU and 512 MiB. The image MUST ship the 65532 UID/GID — agents that + * require root will fail at container start. + */ + val marketplace: DockerExecutionTrustPolicy = DockerExecutionTrustPolicy( + readOnlyRootFilesystem = true, + nanoCpus = 1_000_000_000, // Docker nano-CPU units: 1e9 == 1 vCPU + memoryLimitBytes = 512L * 1024L * 1024L, // 512 MiB + user = "65532:65532", // distroless 'nonroot' UID/GID + tmpFs = defaultDockerTmpFs(), + ), +) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt b/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt index 8704d04e..2d8d38e3 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt @@ -7,4 +7,5 @@ data class SecurityConfig( * set it to true and understand the risks involved. */ val enableReferencedExporting: Boolean = false, -) \ No newline at end of file + val allowUntrustedExecutableRuntime: Boolean = false, +) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt index 004d4c13..4eb18833 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt @@ -570,7 +570,7 @@ class SessionAgent( status = status.value, description = description, links = links.map { it.name }.toSet(), - annotations = graphAgent.annotations + annotations = graphAgent.annotations, ) /** diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt index 4f574137..ec96476d 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt @@ -2,6 +2,8 @@ package org.coralprotocol.coralserver.session import org.apache.commons.io.file.PathUtils.deleteFile import org.coralprotocol.coralserver.config.DockerConfig +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission import kotlin.io.path.createTempFile import kotlin.io.path.name import kotlin.io.path.writeBytes @@ -14,10 +16,21 @@ sealed interface SessionAgentDisposableResource { val mountPath = "${dockerConfig.containerTemporaryDirectory}${dockerConfig.containerNameSeparator}${file.name}" init { file.writeBytes(data) + try { + Files.setPosixFilePermissions( + file, + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.GROUP_READ, + PosixFilePermission.OTHERS_READ, + ) + ) + } catch (_: UnsupportedOperationException) { + } } override fun dispose() { deleteFile(file) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt index 31d92215..9d1392d0 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt @@ -4,6 +4,8 @@ package org.coralprotocol.coralserver.session import io.ktor.utils.io.* import kotlinx.coroutines.flow.update +import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicy +import org.coralprotocol.coralserver.agent.execution.resolveTrustPolicy import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider import org.coralprotocol.coralserver.agent.registry.option.* import org.coralprotocol.coralserver.agent.runtime.ApplicationRuntimeContext @@ -13,6 +15,7 @@ import org.coralprotocol.coralserver.config.AddressConsumer import org.coralprotocol.coralserver.config.DebugConfig import org.coralprotocol.coralserver.config.DockerConfig import org.coralprotocol.coralserver.config.LlmProxyConfig +import org.coralprotocol.coralserver.config.SecurityConfig import org.coralprotocol.coralserver.events.SessionEvent import org.coralprotocol.coralserver.mcp.McpTransportType import org.coralprotocol.coralserver.session.reporting.SessionAgentUsageReport @@ -42,11 +45,15 @@ class SessionAgentExecutionContext( val debugConfig by inject() val dockerConfig by inject() val llmProxyConfig by inject() + val securityConfig by inject() val disposableResources = mutableListOf() var lastLaunchTime: Instant? = null + val executionPolicy: ExecutionTrustPolicy = + registryAgent.identifier.registrySourceId.resolveTrustPolicy(dockerConfig, securityConfig) + /** * A list of usage reports for this agent. When a session ends, all usage reports for each agent will be sent to * webhooks, if configured. @@ -86,6 +93,18 @@ class SessionAgentExecutionContext( putAll(debugConfig.additionalDockerEnvironment) } + // Read-only rootfs + non-root UID (e.g. distroless 'nonroot' UID 65532) leaves the agent without a + // writable HOME and without a /etc/passwd entry, so libraries that derive paths via getpwuid() land + // on /nonexistent. Redirect HOME/TMPDIR/XDG_* into the tmpfs scratch so caches and config writes + // succeed without giving the agent write access to the rootfs. + if (isContainer && executionPolicy.docker.requiresWritableTmpHome) { + this["HOME"] = dockerConfig.containerTemporaryDirectory + this["TMPDIR"] = dockerConfig.containerTemporaryDirectory + this["XDG_CACHE_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.cache" + this["XDG_CONFIG_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.config" + this["XDG_DATA_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.local/share" + } + // User options options.forEach { (name, value) -> when (value.option().transport) { diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt index 29fcd1fb..1e95d147 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt @@ -30,4 +30,4 @@ data class SessionAgentState( @Description("Token usage broken down by provider/model (e.g. 'openai/gpt-4.1')") val tokensByModel: Map = emptyMap(), -) : SessionResource \ No newline at end of file +) : SessionResource diff --git a/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt index 71ce69ca..aeab4fe2 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt @@ -27,9 +27,14 @@ import org.coralprotocol.coralserver.config.* import org.coralprotocol.coralserver.logging.Logger import org.coralprotocol.coralserver.modules.* import org.coralprotocol.coralserver.modules.ktor.coralServerModule +import org.coralprotocol.coralserver.payment.BlankBlockchainService +import org.coralprotocol.coralserver.payment.BlankX402Service +import org.coralprotocol.coralserver.payment.JupiterService import org.coralprotocol.coralserver.session.LocalSessionManager import org.coralprotocol.coralserver.utils.TestProxy import org.coralprotocol.coralserver.utils.TestProxyConfiguration +import org.coralprotocol.payment.blockchain.BlockchainService +import org.coralprotocol.payment.blockchain.X402Service import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -145,6 +150,7 @@ abstract class CoralTest(body: CoralTest.() -> Unit) : KoinTest, FunSpec(body as registryConfig = RegistryConfig( includeDebugAgents = true, includeCoralHomeAgents = false, + watchLocalAgents = false, localAgents = listOf() ), authConfig = AuthConfig( @@ -211,7 +217,11 @@ abstract class CoralTest(body: CoralTest.() -> Unit) : KoinTest, FunSpec(body as } } }, - blockchainModule, + module { + singleOf(::JupiterService) + single { BlankBlockchainService() } + single { BlankX402Service() } + }, agentModule, module { single { diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt index 0401c3aa..5c3c2a1d 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt @@ -8,7 +8,10 @@ import com.github.dockerjava.core.DockerClientImpl import com.github.dockerjava.httpclient5.ApacheDockerHttpClient import com.github.dockerjava.transport.DockerHttpClient import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.test.TestCase +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe import io.kotest.matchers.nulls.shouldNotBeNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -16,6 +19,11 @@ import kotlinx.coroutines.withContext import org.coralprotocol.coralserver.CoralTest import org.coralprotocol.coralserver.agent.graph.AgentGraph import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider +import org.coralprotocol.coralserver.agent.execution.buildHostConfig +import org.coralprotocol.coralserver.agent.execution.resolveTrustPolicy +import org.coralprotocol.coralserver.agent.execution.sanitizeImage +import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier +import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier import org.coralprotocol.coralserver.agent.registry.option.AgentOption import org.coralprotocol.coralserver.agent.registry.option.AgentOptionTransport import org.coralprotocol.coralserver.agent.registry.option.AgentOptionValue @@ -23,6 +31,8 @@ import org.coralprotocol.coralserver.agent.registry.option.AgentOptionWithValue import org.coralprotocol.coralserver.agent.runtime.ApplicationRuntimeContext import org.coralprotocol.coralserver.agent.runtime.DockerRuntime import org.coralprotocol.coralserver.agent.runtime.RuntimeId +import org.coralprotocol.coralserver.config.DockerConfig +import org.coralprotocol.coralserver.config.SecurityConfig import org.coralprotocol.coralserver.config.RootConfig import org.coralprotocol.coralserver.events.SessionEvent import org.coralprotocol.coralserver.logging.Logger @@ -36,6 +46,7 @@ import org.koin.test.inject import java.time.Duration import java.util.* import kotlin.time.Duration.Companion.seconds +import com.github.dockerjava.api.model.Capability /** * Because these tests interact with a system docker installation, it is generally recommended to skip them. For @@ -229,4 +240,115 @@ class DockerRuntimeTest : CoralTest({ session1.sessionScope.cancel() } -}) \ No newline at end of file + + test("testDockerHostConfigHardeningDefaults") { + val logger by inject(named(LOGGER_LOCAL_SESSION)) + val dockerConfig by inject() + val securityConfig by inject() + + val tier = AgentRegistrySourceIdentifier.Local + .resolveTrustPolicy(dockerConfig, securityConfig).docker + val hostConfig = tier.buildHostConfig(emptyList(), logger) + + hostConfig.privileged shouldBe false + hostConfig.readonlyRootfs shouldBe tier.readOnlyRootFilesystem + hostConfig.securityOpts?.shouldContain("no-new-privileges") + hostConfig.capDrop?.toSet() shouldBe setOf(Capability.ALL) + hostConfig.pidsLimit shouldBe tier.pidsLimit + hostConfig.nanoCPUs shouldBe tier.nanoCpus + hostConfig.memory shouldBe tier.memoryLimitBytes + } + + test("testDockerImageDigestRequiredForMarketplaceAgents") { + val logger by inject(named(LOGGER_LOCAL_SESSION)) + val identifier = RegistryAgentIdentifier( + name = "market-agent", + version = "1.0.0", + registrySourceId = AgentRegistrySourceIdentifier.Marketplace + ) + val strictPolicy = DockerConfig().marketplace.copy(requireImageDigest = true) + + shouldThrow { + strictPolicy.sanitizeImage( + imageName = "ghcr.io/coral-protocol/agent:1.0.0", + id = identifier, + profileName = "marketplace_untrusted", + logger = logger, + ) + } + + strictPolicy.sanitizeImage( + imageName = "ghcr.io/coral-protocol/agent@sha256:abc123", + id = identifier, + profileName = "marketplace_untrusted", + logger = logger, + ) shouldBe "ghcr.io/coral-protocol/agent@sha256:abc123" + } + + test("testMarketplaceDockerRuntimeHardening").config( + invocations = 1, + invocationTimeout = 180.seconds, + enabledIf = ::isDockerAvailable + ) { + val localSessionManager by inject() + val logger by inject(named(LOGGER_LOCAL_SESSION)) + + val optionValue = UUID.randomUUID().toString() + + val (session1, _) = localSessionManager.createSession( + "test", AgentGraph( + agents = mapOf( + graphAgentPair("marketplace") { + provider = GraphAgentProvider.Local(RuntimeId.DOCKER) + registryAgent { + registrySourceId = AgentRegistrySourceIdentifier.Marketplace + runtime( + DockerRuntime( + image = image, + command = listOf( + "sh", "-c", """ + echo HOME: + echo ${'$'}HOME + + echo UID: + id -u + + touch /coral-rootfs-test 2>/dev/null || echo ROOT_FS_READ_ONLY + + echo TEST_FS_OPTION: + cat ${'$'}TEST_FS_OPTION + """.trimIndent() + ) + ) + ) + } + option( + "TEST_FS_OPTION", AgentOptionWithValue.String( + option = run { + val opt = AgentOption.String() + opt.transport = AgentOptionTransport.FILE_SYSTEM + opt + }, + value = AgentOptionValue.String(optionValue) + ) + ) + } + ) + ) + ) + + shouldPostEvents( + timeout = 10.seconds, + allowUnexpectedEvents = true, + events = mutableListOf( + TestEvent("home tmp") { it is LoggingEvent.Info && it.text == "/tmp" }, + TestEvent("uid") { it is LoggingEvent.Info && it.text == "65532" }, + TestEvent("rootfs readonly") { it is LoggingEvent.Info && it.text == "ROOT_FS_READ_ONLY" }, + TestEvent("fs readable") { it is LoggingEvent.Info && it.text == optionValue }, + ), + logger.flow + ) { + session1.fullLifeCycle() + } + } +}) diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt index c46fd0ad..cee686b8 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt @@ -4,6 +4,7 @@ import io.kotest.engine.spec.tempfile import org.coralprotocol.coralserver.CoralTest import org.coralprotocol.coralserver.agent.graph.AgentGraph import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider +import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier import org.coralprotocol.coralserver.agent.registry.option.AgentOption import org.coralprotocol.coralserver.agent.registry.option.AgentOptionTransport import org.coralprotocol.coralserver.agent.registry.option.AgentOptionValue @@ -146,4 +147,42 @@ class ExecutableRuntimeTest : CoralTest({ session1.fullLifeCycle() } } -}) \ No newline at end of file + + test("testMarketplaceExecutableRuntimeBlocked") { + val localSessionManager by inject() + val logger by inject(named(LOGGER_LOCAL_SESSION)) + + val (session1, _) = localSessionManager.createSession( + "test", AgentGraph( + agents = mapOf( + graphAgentPair("marketplace") { + registryAgent { + registrySourceId = AgentRegistrySourceIdentifier.Marketplace + runtime( + ExecutableRuntime( + path = if (isWindows()) "powershell.exe" else "/bin/sh" + ) + ) + } + provider = GraphAgentProvider.Local(RuntimeId.EXECUTABLE) + } + ) + ) + ) + + shouldPostEvents( + timeout = 3.seconds, + allowUnexpectedEvents = true, + events = mutableListOf( + TestEvent("blocked") { + it is LoggingEvent.Error && + it.text.startsWith("Executable runtime is disabled by execution profile '") && + it.text.contains("marketplace_untrusted") + } + ), + logger.flow + ) { + session1.fullLifeCycle() + } + } +}) diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt new file mode 100644 index 00000000..08375ab8 --- /dev/null +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt @@ -0,0 +1,63 @@ +package org.coralprotocol.coralserver.session + +import io.kotest.matchers.shouldBe +import org.coralprotocol.coralserver.CoralTest +import org.coralprotocol.coralserver.agent.execution.resolveTrustPolicy +import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier +import org.coralprotocol.coralserver.config.DockerConfig +import org.coralprotocol.coralserver.config.SecurityConfig +import org.koin.test.inject + +class ExecutionTrustPolicyResolverTest : CoralTest({ + test("testLocalTrustPolicyMirrorsTrustedTierConfig") { + val dockerConfig by inject() + val securityConfig by inject() + + val policy = AgentRegistrySourceIdentifier.Local.resolveTrustPolicy(dockerConfig, securityConfig) + + policy.profileName shouldBe "trusted_local" + policy.allowExecutableRuntime shouldBe true + policy.docker shouldBe dockerConfig.trusted + } + + test("testMarketplaceTrustPolicyMirrorsMarketplaceTierConfig") { + val dockerConfig by inject() + val securityConfig by inject() + + val policy = AgentRegistrySourceIdentifier.Marketplace.resolveTrustPolicy(dockerConfig, securityConfig) + + policy.profileName shouldBe "marketplace_untrusted" + policy.allowExecutableRuntime shouldBe false + policy.docker shouldBe dockerConfig.marketplace + } + + test("testLinkedTrustPolicyInheritsMarketplaceHardening") { + val dockerConfig by inject() + val securityConfig by inject() + + val linked = AgentRegistrySourceIdentifier.Linked("peer-server") + .resolveTrustPolicy(dockerConfig, securityConfig) + val marketplace = AgentRegistrySourceIdentifier.Marketplace + .resolveTrustPolicy(dockerConfig, securityConfig) + + linked shouldBe marketplace + } + + test("testOperatorCanUnblockUntrustedExecutableRuntime") { + val dockerConfig by inject() + val permissive = SecurityConfig(allowUntrustedExecutableRuntime = true) + + AgentRegistrySourceIdentifier.Marketplace.resolveTrustPolicy(dockerConfig, permissive) + .allowExecutableRuntime shouldBe true + } + + test("testOperatorCanRequireMarketplaceDockerImageDigest") { + val securityConfig by inject() + val strict = DockerConfig(marketplace = DockerConfig().marketplace.copy(requireImageDigest = true)) + + AgentRegistrySourceIdentifier.Marketplace.resolveTrustPolicy(strict, securityConfig) + .docker.requireImageDigest shouldBe true + AgentRegistrySourceIdentifier.Local.resolveTrustPolicy(strict, securityConfig) + .docker.requireImageDigest shouldBe false + } +}) diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt index 5e346ae0..7d594c56 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt @@ -16,6 +16,7 @@ import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.maps.shouldHaveSize +import io.kotest.matchers.shouldBe import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.client.* @@ -28,12 +29,14 @@ import io.ktor.server.resources.post import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.coralprotocol.coralserver.CoralTest import org.coralprotocol.coralserver.agent.debug.SeedDebugAgent import org.coralprotocol.coralserver.agent.debug.ToolDebugAgent +import org.coralprotocol.coralserver.agent.graph.AgentGraph import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider import org.coralprotocol.coralserver.agent.graph.GraphAgentTool import org.coralprotocol.coralserver.agent.graph.GraphAgentToolTransport