From f1540a21f24a8dde72c412718d21c59a62b8b749 Mon Sep 17 00:00:00 2001 From: Andri Date: Fri, 17 Apr 2026 12:26:42 +0100 Subject: [PATCH 1/8] docker hardening --- .../agent/execution/ExecutionTrustPolicy.kt | 30 +++++ .../execution/ExecutionTrustPolicyResolver.kt | 51 ++++++++ .../agent/runtime/DockerRuntime.kt | 70 ++++++++++- .../agent/runtime/ExecutableRuntime.kt | 6 + .../coralserver/config/DockerConfig.kt | 22 +++- .../coralserver/config/SecurityConfig.kt | 4 +- .../coralserver/modules/AgentModule.kt | 5 +- .../coralserver/session/SessionAgent.kt | 4 +- .../session/SessionAgentDisposableResource.kt | 14 ++- .../session/SessionAgentExecutionContext.kt | 28 +++++ .../session/state/SessionAgentState.kt | 9 +- .../coralserver/session/DockerRuntimeTest.kt | 117 +++++++++++++++++- .../session/ExecutableRuntimeTest.kt | 39 +++++- .../ExecutionTrustPolicyResolverTest.kt | 80 ++++++++++++ .../coralserver/session/SessionApiTest.kt | 61 +++++++++ 15 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt create mode 100644 src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt create mode 100644 src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt 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..67ff191b --- /dev/null +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt @@ -0,0 +1,30 @@ +package org.coralprotocol.coralserver.agent.execution + +import kotlinx.serialization.Serializable + +@Serializable +enum class ExecutionTrustTier { + TRUSTED, + UNTRUSTED, +} + +@Serializable +data class DockerExecutionTrustPolicy( + val readOnlyRootFilesystem: Boolean, + val noNewPrivileges: Boolean, + val dropCapabilities: Set, + val pidsLimit: Long? = null, + val nanoCpus: Long? = null, + val memoryLimitBytes: Long? = null, + val user: String? = null, + val tmpFs: Map = emptyMap(), + val requireImageDigest: Boolean = false, +) + +@Serializable +data class ExecutionTrustPolicy( + val profileName: String, + val trustTier: ExecutionTrustTier, + val allowExecutableRuntime: Boolean, + val docker: DockerExecutionTrustPolicy, +) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt new file mode 100644 index 00000000..6c0cfda6 --- /dev/null +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt @@ -0,0 +1,51 @@ +package org.coralprotocol.coralserver.agent.execution + +import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier +import org.coralprotocol.coralserver.config.DockerConfig +import org.coralprotocol.coralserver.config.SecurityConfig + +class ExecutionTrustPolicyResolver( + private val securityConfig: SecurityConfig, + private val dockerConfig: DockerConfig, +) { + fun resolve(registrySourceId: AgentRegistrySourceIdentifier): ExecutionTrustPolicy = + when (registrySourceId) { + is AgentRegistrySourceIdentifier.Marketplace -> marketplacePolicy() + is AgentRegistrySourceIdentifier.Local -> trustedLocalPolicy("trusted_local") + is AgentRegistrySourceIdentifier.Linked -> trustedLocalPolicy("linked") + } + + private fun trustedLocalPolicy(profileName: String) = ExecutionTrustPolicy( + profileName = profileName, + trustTier = ExecutionTrustTier.TRUSTED, + allowExecutableRuntime = true, + docker = DockerExecutionTrustPolicy( + readOnlyRootFilesystem = dockerConfig.readOnlyRootFilesystem, + noNewPrivileges = dockerConfig.noNewPrivileges, + dropCapabilities = dockerConfig.dropCapabilities, + pidsLimit = dockerConfig.pidsLimit, + nanoCpus = dockerConfig.nanoCpus, + memoryLimitBytes = dockerConfig.memoryLimitBytes, + user = dockerConfig.user, + tmpFs = dockerConfig.tmpFs, + requireImageDigest = false, + ) + ) + + private fun marketplacePolicy() = ExecutionTrustPolicy( + profileName = "marketplace_untrusted", + trustTier = ExecutionTrustTier.UNTRUSTED, + allowExecutableRuntime = securityConfig.allowMarketplaceExecutableRuntime, + docker = DockerExecutionTrustPolicy( + readOnlyRootFilesystem = dockerConfig.readOnlyRootFilesystem || dockerConfig.marketplaceReadOnlyRootFilesystem, + noNewPrivileges = dockerConfig.noNewPrivileges, + dropCapabilities = dockerConfig.dropCapabilities, + pidsLimit = dockerConfig.marketplacePidsLimit ?: dockerConfig.pidsLimit, + nanoCpus = dockerConfig.marketplaceNanoCpus ?: dockerConfig.nanoCpus, + memoryLimitBytes = dockerConfig.marketplaceMemoryLimitBytes ?: dockerConfig.memoryLimitBytes, + user = dockerConfig.marketplaceUser ?: dockerConfig.user, + tmpFs = dockerConfig.marketplaceTmpFs ?: dockerConfig.tmpFs, + requireImageDigest = securityConfig.requireMarketplaceDockerImageDigest, + ) + ) +} 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..5185da8a 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/DockerRuntime.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/DockerRuntime.kt @@ -11,6 +11,7 @@ import io.ktor.utils.io.* import kotlinx.coroutines.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.coralprotocol.coralserver.agent.execution.DockerExecutionTrustPolicy import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier import org.coralprotocol.coralserver.events.SessionEvent import org.coralprotocol.coralserver.logging.LoggingInterface @@ -41,8 +42,12 @@ data class DockerRuntime( } val docker = applicationRuntimeContext.dockerClient - val sanitisedImageName = - docker.sanitizeDockerImageName(image, executionContext.registryAgent.identifier, executionContext.logger) + val sanitisedImageName = sanitizeDockerImageName( + imageName = image, + id = executionContext.registryAgent.identifier, + logger = executionContext.logger, + requireDigest = executionContext.executionPolicy.docker.requireImageDigest + ) var containerId: String? = null try { @@ -68,15 +73,24 @@ data class DockerRuntime( Bind(it.file.toString(), Volume(it.mountPath)) } + val hostConfig = executionContext.executionPolicy.docker.toHostConfig( + volumes = volumes, + logger = executionContext.logger + ) + val containerCreationCmd = docker.createContainerCmd(sanitisedImageName) .withName(executionContext.agent.secret) .withEnv(environment.map { (key, value) -> "$key=$value" }) - .withHostConfig(HostConfig().withBinds(volumes)) + .withHostConfig(hostConfig) .withAttachStdout(true) .withAttachStderr(true) .withStopTimeout(1) .withAttachStdin(false) // Stdin makes no sense with orchestration + executionContext.executionPolicy.docker.user + ?.takeIf { it.isNotBlank() } + ?.let { containerCreationCmd.withUser(it) } + if (command != null) containerCreationCmd.withCmd(*command.toTypedArray()) @@ -135,11 +149,20 @@ data class DockerRuntime( } } -private fun DockerClient.sanitizeDockerImageName( +internal fun sanitizeDockerImageName( imageName: String, id: RegistryAgentIdentifier, - logger: LoggingInterface + logger: LoggingInterface, + requireDigest: Boolean = false, ): String { + if (imageName.contains("@sha256:")) { + return imageName + } + + if (requireDigest) { + throw IllegalArgumentException("Docker image $imageName must be pinned by digest for marketplace agents") + } + if (imageName.contains(":")) { if (!imageName.endsWith(":${id.version}")) { logger.warn { "Image $imageName does not match the agent version: ${id.version}" } @@ -151,6 +174,43 @@ private fun DockerClient.sanitizeDockerImageName( } } +internal fun DockerExecutionTrustPolicy.toHostConfig( + volumes: List, + logger: LoggingInterface +): HostConfig { + val hostConfig = HostConfig() + .withBinds(volumes) + .withPrivileged(false) + .withReadonlyRootfs(readOnlyRootFilesystem) + + if (noNewPrivileges) { + hostConfig.withSecurityOpts(listOf("no-new-privileges")) + } + + if (readOnlyRootFilesystem && 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 +} + +internal fun Set.toCapabilities(logger: LoggingInterface): List = + mapNotNull { capability -> + runCatching { enumValueOf(capability.uppercase()) } + .onFailure { + logger.warn { "Unknown Docker capability in config: $capability" } + } + .getOrNull() + } + 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..a5f56fec 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,12 @@ data class ExecutableRuntime( executionContext: SessionAgentExecutionContext, applicationRuntimeContext: ApplicationRuntimeContext ) { + if (!executionContext.executionPolicy.allowExecutableRuntime) { + val message = "Executable runtime is disabled for marketplace agents" + 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..40a3269e 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt @@ -49,6 +49,10 @@ private fun defaultDockerSocket(): String { } } +private fun defaultDockerTmpFs(): Map = mapOf( + "/tmp" to "rw,noexec,nosuid,nodev,size=64m" +) + data class DockerConfig( /** * Optional docker socket path @@ -97,5 +101,19 @@ data class DockerConfig( * * @see [containerPathSeparator] */ - val containerTemporaryDirectory: String = "/tmp" -) \ No newline at end of file + val containerTemporaryDirectory: String = "/tmp", + val noNewPrivileges: Boolean = true, + val readOnlyRootFilesystem: Boolean = false, + val dropCapabilities: Set = setOf("ALL"), + val pidsLimit: Long? = 256, + val nanoCpus: Long? = null, + val memoryLimitBytes: Long? = null, + val user: String? = null, + val tmpFs: Map = defaultDockerTmpFs(), + val marketplaceReadOnlyRootFilesystem: Boolean = true, + val marketplacePidsLimit: Long? = 256, + val marketplaceNanoCpus: Long? = 1_000_000_000, + val marketplaceMemoryLimitBytes: Long? = 512L * 1024L * 1024L, + val marketplaceUser: String? = "65532:65532", + val marketplaceTmpFs: Map? = null, +) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt b/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt index 8704d04e..9217adfb 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt @@ -7,4 +7,6 @@ data class SecurityConfig( * set it to true and understand the risks involved. */ val enableReferencedExporting: Boolean = false, -) \ No newline at end of file + val allowMarketplaceExecutableRuntime: Boolean = false, + val requireMarketplaceDockerImageDigest: Boolean = false, +) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt b/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt index cad33e9d..c435019b 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking import org.coralprotocol.coralserver.agent.debug.* +import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicyResolver import org.coralprotocol.coralserver.agent.registry.AgentRegistry import org.coralprotocol.coralserver.config.RegistryConfig import org.coralprotocol.coralserver.mcp.McpToolManager @@ -16,6 +17,8 @@ import java.nio.file.Path const val AGENT_WATCHER_COROUTINE_SCOPE_NAME = "agentWatcherCoroutineScope" val agentModule = module { + singleOf(::ExecutionTrustPolicyResolver) + singleOf(::EchoDebugAgent) singleOf(::SeedDebugAgent) singleOf(::ToolDebugAgent) @@ -71,4 +74,4 @@ val agentModule = module { single(named(AGENT_WATCHER_COROUTINE_SCOPE_NAME)) { CoroutineScope(Dispatchers.IO) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt index 004d4c13..5897ae2f 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt @@ -570,7 +570,9 @@ class SessionAgent( status = status.value, description = description, links = links.map { it.name }.toSet(), - annotations = graphAgent.annotations + annotations = graphAgent.annotations, + executionProfile = executionContext.executionPolicy.profileName, + trustTier = executionContext.executionPolicy.trustTier, ) /** diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt index 4f574137..e452075d 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,20 @@ sealed interface SessionAgentDisposableResource { val mountPath = "${dockerConfig.containerTemporaryDirectory}${dockerConfig.containerNameSeparator}${file.name}" init { file.writeBytes(data) + runCatching { + Files.setPosixFilePermissions( + file, + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.GROUP_READ, + PosixFilePermission.OTHERS_READ, + ) + ) + } } 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..49b34159 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt @@ -4,7 +4,11 @@ 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.ExecutionTrustPolicyResolver +import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider +import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier import org.coralprotocol.coralserver.agent.registry.option.* import org.coralprotocol.coralserver.agent.runtime.ApplicationRuntimeContext import org.coralprotocol.coralserver.agent.runtime.DEFAULT_AGENT_RUNTIME_TRANSPORT @@ -42,11 +46,24 @@ class SessionAgentExecutionContext( val debugConfig by inject() val dockerConfig by inject() val llmProxyConfig by inject() + val executionTrustPolicyResolver by inject() val disposableResources = mutableListOf() var lastLaunchTime: Instant? = null + val isMarketplaceAgent = registryAgent.identifier.registrySourceId is AgentRegistrySourceIdentifier.Marketplace + + val executionPolicy: ExecutionTrustPolicy by lazy { + executionTrustPolicyResolver.resolve(registryAgent.identifier.registrySourceId) + } + + val executionTrustTier: String + get() = executionPolicy.trustTier.name.lowercase() + + val executionProfileName: String + get() = executionPolicy.profileName + /** * 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 +103,14 @@ class SessionAgentExecutionContext( putAll(debugConfig.additionalDockerEnvironment) } + if (provider.runtime == RuntimeId.DOCKER && executionPolicy.trustTier == ExecutionTrustTier.UNTRUSTED) { + 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) { @@ -120,6 +145,9 @@ class SessionAgentExecutionContext( this["CORAL_SESSION_ID"] = agent.session.id this["CORAL_API_URL"] = applicationRuntimeContext.getApiUrl(addressConsumer).toString() this["CORAL_RUNTIME_ID"] = provider.runtime.toString().lowercase() + this["CORAL_REGISTRY_SOURCE"] = registryAgent.identifier.registrySourceId.toString() + this["CORAL_TRUST_TIER"] = executionTrustTier + this["CORAL_EXECUTION_PROFILE"] = executionProfileName if (agent.graphAgent.systemPrompt != null) this["CORAL_PROMPT_SYSTEM"] = agent.graphAgent.systemPrompt 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..20e99b43 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt @@ -2,6 +2,7 @@ package org.coralprotocol.coralserver.session.state import io.github.smiley4.schemakenerator.core.annotations.Description import kotlinx.serialization.Serializable +import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier import org.coralprotocol.coralserver.agent.graph.UniqueAgentName import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier import org.coralprotocol.coralserver.llmproxy.TokenUsage @@ -28,6 +29,12 @@ data class SessionAgentState( override val annotations: Map, + @Description("Resolved execution profile applied to this agent") + val executionProfile: String, + + @Description("Resolved trust tier applied to this agent") + val trustTier: ExecutionTrustTier, + @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/session/DockerRuntimeTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt index 0401c3aa..dbba5cc1 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt @@ -8,7 +8,9 @@ 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.shouldBe import io.kotest.matchers.nulls.shouldNotBeNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -16,6 +18,9 @@ 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.ExecutionTrustPolicyResolver +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 +28,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.agent.runtime.sanitizeDockerImageName +import org.coralprotocol.coralserver.agent.runtime.toHostConfig import org.coralprotocol.coralserver.config.RootConfig import org.coralprotocol.coralserver.events.SessionEvent import org.coralprotocol.coralserver.logging.Logger @@ -36,6 +43,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 +237,111 @@ class DockerRuntimeTest : CoralTest({ session1.sessionScope.cancel() } -}) \ No newline at end of file + + test("testDockerHostConfigHardeningDefaults") { + val logger by inject(named(LOGGER_LOCAL_SESSION)) + val resolver by inject() + + val hostConfig = resolver.resolve(AgentRegistrySourceIdentifier.Local).docker.toHostConfig(emptyList(), logger) + + hostConfig.privileged shouldBe false + hostConfig.readonlyRootfs shouldBe false + hostConfig.securityOpts shouldBe listOf("no-new-privileges") + hostConfig.capDrop?.toSet() shouldBe setOf(Capability.ALL) + hostConfig.pidsLimit shouldBe 256L + hostConfig.nanoCPUs shouldBe null + hostConfig.memory shouldBe null + } + + test("testDockerImageDigestRequiredForMarketplaceAgents") { + val logger by inject(named(LOGGER_LOCAL_SESSION)) + val identifier = RegistryAgentIdentifier( + name = "market-agent", + version = "1.0.0", + registrySourceId = AgentRegistrySourceIdentifier.Marketplace + ) + + shouldThrow { + sanitizeDockerImageName( + imageName = "ghcr.io/coral-protocol/agent:1.0.0", + id = identifier, + logger = logger, + requireDigest = true + ) + } + + sanitizeDockerImageName( + imageName = "ghcr.io/coral-protocol/agent@sha256:abc123", + id = identifier, + logger = logger, + requireDigest = true + ) 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..925ff0c0 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,40 @@ 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 == "Executable runtime is disabled for marketplace agents" + } + ), + 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..3ef6e2ae --- /dev/null +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt @@ -0,0 +1,80 @@ +package org.coralprotocol.coralserver.session + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.cancel +import org.coralprotocol.coralserver.CoralTest +import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicyResolver +import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier +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.runtime.FunctionRuntime +import org.coralprotocol.coralserver.agent.runtime.RuntimeId +import org.coralprotocol.coralserver.utils.dsl.graphAgentPair +import org.koin.test.inject + +class ExecutionTrustPolicyResolverTest : CoralTest({ + test("testMarketplaceTrustPolicyDefaults") { + val resolver by inject() + + val policy = resolver.resolve(AgentRegistrySourceIdentifier.Marketplace) + + policy.profileName shouldBe "marketplace_untrusted" + policy.trustTier shouldBe ExecutionTrustTier.UNTRUSTED + policy.allowExecutableRuntime shouldBe false + policy.docker.requireImageDigest shouldBe false + policy.docker.readOnlyRootFilesystem shouldBe true + policy.docker.pidsLimit shouldBe 256L + policy.docker.nanoCpus shouldBe 1_000_000_000L + policy.docker.memoryLimitBytes shouldBe 512L * 1024L * 1024L + policy.docker.user shouldBe "65532:65532" + } + + test("testLocalTrustPolicyDefaults") { + val resolver by inject() + + val policy = resolver.resolve(AgentRegistrySourceIdentifier.Local) + + policy.profileName shouldBe "trusted_local" + policy.trustTier shouldBe ExecutionTrustTier.TRUSTED + policy.allowExecutableRuntime shouldBe true + policy.docker.requireImageDigest shouldBe false + policy.docker.pidsLimit shouldBe 256L + policy.docker.nanoCpus shouldBe null + policy.docker.memoryLimitBytes shouldBe null + } + + test("testSessionStateIncludesResolvedTrustPolicy") { + val localSessionManager by inject() + + val (session, _) = localSessionManager.createSession( + "test", AgentGraph( + agents = mapOf( + graphAgentPair("marketplace") { + registryAgent { + registrySourceId = AgentRegistrySourceIdentifier.Marketplace + runtime(FunctionRuntime()) + } + provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) + }, + graphAgentPair("local") { + registryAgent { + registrySourceId = AgentRegistrySourceIdentifier.Local + runtime(FunctionRuntime()) + } + provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) + } + ) + ) + ) + + val states = session.getState().agents.associateBy { it.name } + + states.getValue("marketplace").executionProfile shouldBe "marketplace_untrusted" + states.getValue("marketplace").trustTier shouldBe ExecutionTrustTier.UNTRUSTED + states.getValue("local").executionProfile shouldBe "trusted_local" + states.getValue("local").trustTier shouldBe ExecutionTrustTier.TRUSTED + + session.sessionScope.cancel() + } +}) diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt index 5e346ae0..8f250f2c 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.* @@ -34,6 +35,7 @@ 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.execution.ExecutionTrustTier import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider import org.coralprotocol.coralserver.agent.graph.GraphAgentTool import org.coralprotocol.coralserver.agent.graph.GraphAgentToolTransport @@ -236,6 +238,65 @@ class SessionApiTest : CoralTest({ localSessionManager.waitAllSessions() } + test("testSessionApiExposesExecutionTrustPosture") { + val client by inject() + val registry by inject() + + val postureName = "posture-agent" + val postureVersion = "1.0.0" + val postureIdentifier = RegistryAgentIdentifier( + postureName, + postureVersion, + AgentRegistrySourceIdentifier.Local + ) + + registry.sources.add( + ListAgentRegistrySource( + "posture agents", + listOf( + registryAgent(postureName) { + version = postureVersion + runtime(FunctionRuntime()) + registrySourceId = AgentRegistrySourceIdentifier.Local + } + ) + ) + ) + + val sessionId: SessionIdentifier = client.authenticatedPost(LocalSessions.Session()) { + setBody( + sessionRequest { + agentGraphRequest { + agent(postureIdentifier) { + provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) + } + isolateAllAgents() + } + createNamespaceIfNotExists { + name = namespaceName + } + immediateExecution { + persistenceMode = SessionPersistenceMode.HoldAfterExit(1000) + } + } + ) + }.shouldBeOK().body() + + val state: SessionStateExtended = + client.authenticatedGet( + LocalSessions.Session.Existing.Extended( + LocalSessions.Session.Existing( + namespace = sessionId.namespace, + sessionId = sessionId.sessionId + ) + ) + ).shouldBeOK().body() + + val agentState = state.agents.single { it.name == postureName } + agentState.executionProfile shouldBe "trusted_local" + agentState.trustTier shouldBe ExecutionTrustTier.TRUSTED + } + test("testSessionPersistence") { val client by inject() From 7890b9d6a83f284b6435041af07cd82fb9fc47cd Mon Sep 17 00:00:00 2001 From: Andri Date: Mon, 20 Apr 2026 16:21:07 +0100 Subject: [PATCH 2/8] tests and cleanup --- .../execution/ExecutionTrustPolicyResolver.kt | 47 ++++---- .../agent/runtime/DockerRuntime.kt | 44 +------- .../agent/runtime/ExecutableRuntime.kt | 3 +- .../coralserver/config/DockerConfig.kt | 21 ++-- .../session/SessionAgentDisposableResource.kt | 3 +- .../session/SessionAgentExecutionContext.kt | 21 +--- .../coralprotocol/coralserver/CoralTest.kt | 11 +- .../coralserver/session/DockerRuntimeTest.kt | 16 ++- .../session/ExecutableRuntimeTest.kt | 4 +- .../ExecutionTrustPolicyResolverTest.kt | 103 +++++++++--------- 10 files changed, 125 insertions(+), 148 deletions(-) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt index 6c0cfda6..f61f5b9c 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt @@ -2,6 +2,7 @@ package org.coralprotocol.coralserver.agent.execution import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier import org.coralprotocol.coralserver.config.DockerConfig +import org.coralprotocol.coralserver.config.DockerTierDefaults import org.coralprotocol.coralserver.config.SecurityConfig class ExecutionTrustPolicyResolver( @@ -10,42 +11,38 @@ class ExecutionTrustPolicyResolver( ) { fun resolve(registrySourceId: AgentRegistrySourceIdentifier): ExecutionTrustPolicy = when (registrySourceId) { - is AgentRegistrySourceIdentifier.Marketplace -> marketplacePolicy() - is AgentRegistrySourceIdentifier.Local -> trustedLocalPolicy("trusted_local") - is AgentRegistrySourceIdentifier.Linked -> trustedLocalPolicy("linked") + is AgentRegistrySourceIdentifier.Local -> trustedLocalPolicy() + is AgentRegistrySourceIdentifier.Marketplace, + is AgentRegistrySourceIdentifier.Linked -> marketplacePolicy() } - private fun trustedLocalPolicy(profileName: String) = ExecutionTrustPolicy( - profileName = profileName, + private fun trustedLocalPolicy() = ExecutionTrustPolicy( + profileName = "trusted_local", trustTier = ExecutionTrustTier.TRUSTED, allowExecutableRuntime = true, - docker = DockerExecutionTrustPolicy( - readOnlyRootFilesystem = dockerConfig.readOnlyRootFilesystem, - noNewPrivileges = dockerConfig.noNewPrivileges, - dropCapabilities = dockerConfig.dropCapabilities, - pidsLimit = dockerConfig.pidsLimit, - nanoCpus = dockerConfig.nanoCpus, - memoryLimitBytes = dockerConfig.memoryLimitBytes, - user = dockerConfig.user, - tmpFs = dockerConfig.tmpFs, - requireImageDigest = false, - ) + docker = dockerPolicyFor(dockerConfig.trusted, requireImageDigest = false), ) private fun marketplacePolicy() = ExecutionTrustPolicy( profileName = "marketplace_untrusted", trustTier = ExecutionTrustTier.UNTRUSTED, allowExecutableRuntime = securityConfig.allowMarketplaceExecutableRuntime, - docker = DockerExecutionTrustPolicy( - readOnlyRootFilesystem = dockerConfig.readOnlyRootFilesystem || dockerConfig.marketplaceReadOnlyRootFilesystem, + docker = dockerPolicyFor( + tier = dockerConfig.marketplace, + requireImageDigest = securityConfig.requireMarketplaceDockerImageDigest, + ), + ) + + private fun dockerPolicyFor(tier: DockerTierDefaults, requireImageDigest: Boolean) = + DockerExecutionTrustPolicy( + readOnlyRootFilesystem = tier.readOnlyRootFilesystem, noNewPrivileges = dockerConfig.noNewPrivileges, dropCapabilities = dockerConfig.dropCapabilities, - pidsLimit = dockerConfig.marketplacePidsLimit ?: dockerConfig.pidsLimit, - nanoCpus = dockerConfig.marketplaceNanoCpus ?: dockerConfig.nanoCpus, - memoryLimitBytes = dockerConfig.marketplaceMemoryLimitBytes ?: dockerConfig.memoryLimitBytes, - user = dockerConfig.marketplaceUser ?: dockerConfig.user, - tmpFs = dockerConfig.marketplaceTmpFs ?: dockerConfig.tmpFs, - requireImageDigest = securityConfig.requireMarketplaceDockerImageDigest, + pidsLimit = tier.pidsLimit, + nanoCpus = tier.nanoCpus, + memoryLimitBytes = tier.memoryLimitBytes, + user = tier.user, + tmpFs = tier.tmpFs, + requireImageDigest = requireImageDigest, ) - ) } 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 5185da8a..ca0f1d34 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,7 @@ import io.ktor.utils.io.* import kotlinx.coroutines.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.coralprotocol.coralserver.agent.execution.DockerExecutionTrustPolicy +import org.coralprotocol.coralserver.agent.execution.toHostConfig import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier import org.coralprotocol.coralserver.events.SessionEvent import org.coralprotocol.coralserver.logging.LoggingInterface @@ -130,6 +130,9 @@ data class DockerRuntime( @OptIn(InternalAPI::class) if (e.rootCause is InterruptedException) throw CancellationException("Docker timeout", e) + + executionContext.logger.error(e) { "Docker client error" } + throw e } finally { withContext(NonCancellable) { when (val containerId = containerId) { @@ -160,7 +163,7 @@ internal fun sanitizeDockerImageName( } if (requireDigest) { - throw IllegalArgumentException("Docker image $imageName must be pinned by digest for marketplace agents") + throw IllegalArgumentException("Docker image $imageName must be pinned by digest (@sha256:...) to satisfy the active execution profile") } if (imageName.contains(":")) { @@ -174,43 +177,6 @@ internal fun sanitizeDockerImageName( } } -internal fun DockerExecutionTrustPolicy.toHostConfig( - volumes: List, - logger: LoggingInterface -): HostConfig { - val hostConfig = HostConfig() - .withBinds(volumes) - .withPrivileged(false) - .withReadonlyRootfs(readOnlyRootFilesystem) - - if (noNewPrivileges) { - hostConfig.withSecurityOpts(listOf("no-new-privileges")) - } - - if (readOnlyRootFilesystem && 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 -} - -internal fun Set.toCapabilities(logger: LoggingInterface): List = - mapNotNull { capability -> - runCatching { enumValueOf(capability.uppercase()) } - .onFailure { - logger.warn { "Unknown Docker capability in config: $capability" } - } - .getOrNull() - } - 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 a5f56fec..a89af101 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/ExecutableRuntime.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/runtime/ExecutableRuntime.kt @@ -26,7 +26,8 @@ data class ExecutableRuntime( applicationRuntimeContext: ApplicationRuntimeContext ) { if (!executionContext.executionPolicy.allowExecutableRuntime) { - val message = "Executable runtime is disabled for marketplace agents" + val message = + "Executable runtime is disabled by execution profile '${executionContext.executionPolicy.profileName}'" executionContext.logger.error { message } throw IllegalStateException(message) } diff --git a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt index 40a3269e..0e912098 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt @@ -1,8 +1,5 @@ package org.coralprotocol.coralserver.config -import com.sksamuel.hoplite.ConfigAlias -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import org.coralprotocol.coralserver.util.isWindows import java.io.File @@ -103,17 +100,21 @@ data class DockerConfig( */ val containerTemporaryDirectory: String = "/tmp", val noNewPrivileges: Boolean = true, - val readOnlyRootFilesystem: Boolean = false, val dropCapabilities: Set = setOf("ALL"), + val trusted: DockerTierDefaults = DockerTierDefaults(), + val marketplace: DockerTierDefaults = DockerTierDefaults( + readOnlyRootFilesystem = true, + nanoCpus = 1_000_000_000, + memoryLimitBytes = 512L * 1024L * 1024L, + user = "65532:65532", + ), +) + +data class DockerTierDefaults( + val readOnlyRootFilesystem: Boolean = false, val pidsLimit: Long? = 256, val nanoCpus: Long? = null, val memoryLimitBytes: Long? = null, val user: String? = null, val tmpFs: Map = defaultDockerTmpFs(), - val marketplaceReadOnlyRootFilesystem: Boolean = true, - val marketplacePidsLimit: Long? = 256, - val marketplaceNanoCpus: Long? = 1_000_000_000, - val marketplaceMemoryLimitBytes: Long? = 512L * 1024L * 1024L, - val marketplaceUser: String? = "65532:65532", - val marketplaceTmpFs: Map? = null, ) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt index e452075d..ec96476d 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentDisposableResource.kt @@ -16,7 +16,7 @@ sealed interface SessionAgentDisposableResource { val mountPath = "${dockerConfig.containerTemporaryDirectory}${dockerConfig.containerNameSeparator}${file.name}" init { file.writeBytes(data) - runCatching { + try { Files.setPosixFilePermissions( file, setOf( @@ -25,6 +25,7 @@ sealed interface SessionAgentDisposableResource { PosixFilePermission.OTHERS_READ, ) ) + } catch (_: UnsupportedOperationException) { } } diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt index 49b34159..0afed932 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt @@ -6,9 +6,7 @@ import io.ktor.utils.io.* import kotlinx.coroutines.flow.update import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicy import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicyResolver -import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider -import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier import org.coralprotocol.coralserver.agent.registry.option.* import org.coralprotocol.coralserver.agent.runtime.ApplicationRuntimeContext import org.coralprotocol.coralserver.agent.runtime.DEFAULT_AGENT_RUNTIME_TRANSPORT @@ -52,17 +50,8 @@ class SessionAgentExecutionContext( var lastLaunchTime: Instant? = null - val isMarketplaceAgent = registryAgent.identifier.registrySourceId is AgentRegistrySourceIdentifier.Marketplace - - val executionPolicy: ExecutionTrustPolicy by lazy { + val executionPolicy: ExecutionTrustPolicy = executionTrustPolicyResolver.resolve(registryAgent.identifier.registrySourceId) - } - - val executionTrustTier: String - get() = executionPolicy.trustTier.name.lowercase() - - val executionProfileName: String - get() = executionPolicy.profileName /** * A list of usage reports for this agent. When a session ends, all usage reports for each agent will be sent to @@ -103,7 +92,8 @@ class SessionAgentExecutionContext( putAll(debugConfig.additionalDockerEnvironment) } - if (provider.runtime == RuntimeId.DOCKER && executionPolicy.trustTier == ExecutionTrustTier.UNTRUSTED) { + val dockerPolicy = executionPolicy.docker + if (isContainer && (dockerPolicy.readOnlyRootFilesystem || dockerPolicy.user != null)) { this["HOME"] = dockerConfig.containerTemporaryDirectory this["TMPDIR"] = dockerConfig.containerTemporaryDirectory this["XDG_CACHE_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.cache" @@ -145,9 +135,10 @@ class SessionAgentExecutionContext( this["CORAL_SESSION_ID"] = agent.session.id this["CORAL_API_URL"] = applicationRuntimeContext.getApiUrl(addressConsumer).toString() this["CORAL_RUNTIME_ID"] = provider.runtime.toString().lowercase() + this["CORAL_REGISTRY_SOURCE"] = registryAgent.identifier.registrySourceId.toString() - this["CORAL_TRUST_TIER"] = executionTrustTier - this["CORAL_EXECUTION_PROFILE"] = executionProfileName + this["CORAL_TRUST_TIER"] = executionPolicy.trustTier.name.lowercase() + this["CORAL_EXECUTION_PROFILE"] = executionPolicy.profileName if (agent.graphAgent.systemPrompt != null) this["CORAL_PROMPT_SYSTEM"] = agent.graphAgent.systemPrompt diff --git a/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt index 71ce69ca..333cfa5b 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 @@ -211,7 +216,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 dbba5cc1..7bba5677 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt @@ -10,6 +10,7 @@ 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 @@ -28,8 +29,9 @@ 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.agent.execution.toHostConfig import org.coralprotocol.coralserver.agent.runtime.sanitizeDockerImageName -import org.coralprotocol.coralserver.agent.runtime.toHostConfig +import org.coralprotocol.coralserver.config.DockerConfig import org.coralprotocol.coralserver.config.RootConfig import org.coralprotocol.coralserver.events.SessionEvent import org.coralprotocol.coralserver.logging.Logger @@ -241,16 +243,18 @@ class DockerRuntimeTest : CoralTest({ test("testDockerHostConfigHardeningDefaults") { val logger by inject(named(LOGGER_LOCAL_SESSION)) val resolver by inject() + val dockerConfig by inject() val hostConfig = resolver.resolve(AgentRegistrySourceIdentifier.Local).docker.toHostConfig(emptyList(), logger) + val tier = dockerConfig.trusted hostConfig.privileged shouldBe false - hostConfig.readonlyRootfs shouldBe false - hostConfig.securityOpts shouldBe listOf("no-new-privileges") + hostConfig.readonlyRootfs shouldBe tier.readOnlyRootFilesystem + hostConfig.securityOpts?.shouldContain("no-new-privileges") hostConfig.capDrop?.toSet() shouldBe setOf(Capability.ALL) - hostConfig.pidsLimit shouldBe 256L - hostConfig.nanoCPUs shouldBe null - hostConfig.memory shouldBe null + hostConfig.pidsLimit shouldBe tier.pidsLimit + hostConfig.nanoCPUs shouldBe tier.nanoCpus + hostConfig.memory shouldBe tier.memoryLimitBytes } test("testDockerImageDigestRequiredForMarketplaceAgents") { diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt index 925ff0c0..cee686b8 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutableRuntimeTest.kt @@ -175,7 +175,9 @@ class ExecutableRuntimeTest : CoralTest({ allowUnexpectedEvents = true, events = mutableListOf( TestEvent("blocked") { - it is LoggingEvent.Error && it.text == "Executable runtime is disabled for marketplace agents" + it is LoggingEvent.Error && + it.text.startsWith("Executable runtime is disabled by execution profile '") && + it.text.contains("marketplace_untrusted") } ), logger.flow diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt index 3ef6e2ae..017df23c 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt @@ -1,80 +1,85 @@ package org.coralprotocol.coralserver.session import io.kotest.matchers.shouldBe -import kotlinx.coroutines.cancel import org.coralprotocol.coralserver.CoralTest import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicyResolver import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier -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.runtime.FunctionRuntime -import org.coralprotocol.coralserver.agent.runtime.RuntimeId -import org.coralprotocol.coralserver.utils.dsl.graphAgentPair +import org.coralprotocol.coralserver.config.DockerConfig +import org.coralprotocol.coralserver.config.SecurityConfig import org.koin.test.inject class ExecutionTrustPolicyResolverTest : CoralTest({ - test("testMarketplaceTrustPolicyDefaults") { + test("testLocalTrustPolicyMirrorsTrustedTierConfig") { val resolver by inject() + val dockerConfig by inject() + + val policy = resolver.resolve(AgentRegistrySourceIdentifier.Local) + val tier = dockerConfig.trusted + + policy.profileName shouldBe "trusted_local" + policy.trustTier shouldBe ExecutionTrustTier.TRUSTED + policy.allowExecutableRuntime shouldBe true + policy.docker.requireImageDigest shouldBe false + policy.docker.readOnlyRootFilesystem shouldBe tier.readOnlyRootFilesystem + policy.docker.pidsLimit shouldBe tier.pidsLimit + policy.docker.nanoCpus shouldBe tier.nanoCpus + policy.docker.memoryLimitBytes shouldBe tier.memoryLimitBytes + policy.docker.user shouldBe tier.user + policy.docker.tmpFs shouldBe tier.tmpFs + } + + test("testMarketplaceTrustPolicyMirrorsMarketplaceTierConfig") { + val resolver by inject() + val dockerConfig by inject() val policy = resolver.resolve(AgentRegistrySourceIdentifier.Marketplace) + val tier = dockerConfig.marketplace policy.profileName shouldBe "marketplace_untrusted" policy.trustTier shouldBe ExecutionTrustTier.UNTRUSTED policy.allowExecutableRuntime shouldBe false policy.docker.requireImageDigest shouldBe false - policy.docker.readOnlyRootFilesystem shouldBe true - policy.docker.pidsLimit shouldBe 256L - policy.docker.nanoCpus shouldBe 1_000_000_000L - policy.docker.memoryLimitBytes shouldBe 512L * 1024L * 1024L - policy.docker.user shouldBe "65532:65532" + policy.docker.readOnlyRootFilesystem shouldBe tier.readOnlyRootFilesystem + policy.docker.pidsLimit shouldBe tier.pidsLimit + policy.docker.nanoCpus shouldBe tier.nanoCpus + policy.docker.memoryLimitBytes shouldBe tier.memoryLimitBytes + policy.docker.user shouldBe tier.user + policy.docker.tmpFs shouldBe tier.tmpFs } - test("testLocalTrustPolicyDefaults") { + test("testLinkedTrustPolicyInheritsMarketplaceHardening") { val resolver by inject() - val policy = resolver.resolve(AgentRegistrySourceIdentifier.Local) + val linked = resolver.resolve(AgentRegistrySourceIdentifier.Linked("peer-server")) + val marketplace = resolver.resolve(AgentRegistrySourceIdentifier.Marketplace) - policy.profileName shouldBe "trusted_local" - policy.trustTier shouldBe ExecutionTrustTier.TRUSTED - policy.allowExecutableRuntime shouldBe true - policy.docker.requireImageDigest shouldBe false - policy.docker.pidsLimit shouldBe 256L - policy.docker.nanoCpus shouldBe null - policy.docker.memoryLimitBytes shouldBe null + linked shouldBe marketplace } - test("testSessionStateIncludesResolvedTrustPolicy") { - val localSessionManager by inject() - - val (session, _) = localSessionManager.createSession( - "test", AgentGraph( - agents = mapOf( - graphAgentPair("marketplace") { - registryAgent { - registrySourceId = AgentRegistrySourceIdentifier.Marketplace - runtime(FunctionRuntime()) - } - provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) - }, - graphAgentPair("local") { - registryAgent { - registrySourceId = AgentRegistrySourceIdentifier.Local - runtime(FunctionRuntime()) - } - provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) - } - ) - ) + test("testOperatorCanUnblockMarketplaceExecutableRuntime") { + val dockerConfig by inject() + + val permissiveResolver = ExecutionTrustPolicyResolver( + securityConfig = SecurityConfig(allowMarketplaceExecutableRuntime = true), + dockerConfig = dockerConfig, ) - val states = session.getState().agents.associateBy { it.name } + permissiveResolver.resolve(AgentRegistrySourceIdentifier.Marketplace) + .allowExecutableRuntime shouldBe true + } + + test("testOperatorCanRequireMarketplaceDockerImageDigest") { + val dockerConfig by inject() - states.getValue("marketplace").executionProfile shouldBe "marketplace_untrusted" - states.getValue("marketplace").trustTier shouldBe ExecutionTrustTier.UNTRUSTED - states.getValue("local").executionProfile shouldBe "trusted_local" - states.getValue("local").trustTier shouldBe ExecutionTrustTier.TRUSTED + val strictResolver = ExecutionTrustPolicyResolver( + securityConfig = SecurityConfig(requireMarketplaceDockerImageDigest = true), + dockerConfig = dockerConfig, + ) - session.sessionScope.cancel() + strictResolver.resolve(AgentRegistrySourceIdentifier.Marketplace) + .docker.requireImageDigest shouldBe true + strictResolver.resolve(AgentRegistrySourceIdentifier.Local) + .docker.requireImageDigest shouldBe false } }) From f75657065086cef1b783660ec3570f4e3433e2b4 Mon Sep 17 00:00:00 2001 From: Andri Date: Mon, 20 Apr 2026 16:23:37 +0100 Subject: [PATCH 3/8] utils --- .../execution/DockerHostConfigBuilder.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt new file mode 100644 index 00000000..197d929d --- /dev/null +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt @@ -0,0 +1,43 @@ +package org.coralprotocol.coralserver.agent.execution + +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.logging.LoggingInterface + +fun DockerExecutionTrustPolicy.toHostConfig( + volumes: List, + logger: LoggingInterface, +): HostConfig { + val hostConfig = HostConfig() + .withBinds(volumes) + .withPrivileged(false) + .withReadonlyRootfs(readOnlyRootFilesystem) + + if (noNewPrivileges) { + hostConfig.withSecurityOpts(listOf("no-new-privileges")) + } + + if (readOnlyRootFilesystem && 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 +} + +internal fun Set.toCapabilities(logger: LoggingInterface): List = + mapNotNull { capability -> + runCatching { enumValueOf(capability.uppercase()) } + .onFailure { + logger.warn { "Unknown Docker capability in config: $capability" } + } + .getOrNull() + } From cec2e511fbd542038ca32bf51c6f4c5fcbd9b318 Mon Sep 17 00:00:00 2001 From: Andri Date: Tue, 21 Apr 2026 12:12:30 +0100 Subject: [PATCH 4/8] rework trust policy --- .../execution/DockerHostConfigBuilder.kt | 43 ------- .../agent/execution/ExecutionTrustPolicy.kt | 120 ++++++++++++++++-- .../execution/ExecutionTrustPolicyResolver.kt | 48 ------- .../agent/runtime/DockerRuntime.kt | 51 ++------ .../coralserver/config/DockerConfig.kt | 20 +-- .../coralserver/config/SecurityConfig.kt | 3 +- .../coralserver/modules/AgentModule.kt | 3 - .../session/SessionAgentExecutionContext.kt | 12 +- .../coralprotocol/coralserver/CoralTest.kt | 1 + .../coralserver/session/DockerRuntimeTest.kt | 23 ++-- .../ExecutionTrustPolicyResolverTest.kt | 59 +++------ .../coralserver/session/SessionApiTest.kt | 70 +++++----- 12 files changed, 199 insertions(+), 254 deletions(-) delete mode 100644 src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt delete mode 100644 src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt deleted file mode 100644 index 197d929d..00000000 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/DockerHostConfigBuilder.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.coralprotocol.coralserver.agent.execution - -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.logging.LoggingInterface - -fun DockerExecutionTrustPolicy.toHostConfig( - volumes: List, - logger: LoggingInterface, -): HostConfig { - val hostConfig = HostConfig() - .withBinds(volumes) - .withPrivileged(false) - .withReadonlyRootfs(readOnlyRootFilesystem) - - if (noNewPrivileges) { - hostConfig.withSecurityOpts(listOf("no-new-privileges")) - } - - if (readOnlyRootFilesystem && 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 -} - -internal 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/execution/ExecutionTrustPolicy.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt index 67ff191b..c5dab537 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt @@ -1,6 +1,15 @@ 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 kotlinx.serialization.Serializable +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 @Serializable enum class ExecutionTrustTier { @@ -8,23 +17,116 @@ enum class ExecutionTrustTier { UNTRUSTED, } -@Serializable data class DockerExecutionTrustPolicy( - val readOnlyRootFilesystem: Boolean, - val noNewPrivileges: Boolean, - val dropCapabilities: Set, - val pidsLimit: Long? = null, + val readOnlyRootFilesystem: Boolean = false, + val noNewPrivileges: Boolean = true, + val dropCapabilities: Set = setOf("ALL"), + 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 +} -@Serializable data class ExecutionTrustPolicy( - val profileName: String, val trustTier: ExecutionTrustTier, val allowExecutableRuntime: Boolean, val docker: DockerExecutionTrustPolicy, -) +) { + val profileName: String = when (trustTier) { + ExecutionTrustTier.TRUSTED -> "trusted_local" + ExecutionTrustTier.UNTRUSTED -> "marketplace_untrusted" + } +} + +fun AgentRegistrySourceIdentifier.resolveTrustPolicy( + dockerConfig: DockerConfig, + securityConfig: SecurityConfig, +): ExecutionTrustPolicy = when (this) { + is AgentRegistrySourceIdentifier.Local -> ExecutionTrustPolicy( + trustTier = ExecutionTrustTier.TRUSTED, + allowExecutableRuntime = true, + docker = dockerConfig.trusted, + ) + is AgentRegistrySourceIdentifier.Marketplace, + is AgentRegistrySourceIdentifier.Linked -> ExecutionTrustPolicy( + trustTier = ExecutionTrustTier.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/execution/ExecutionTrustPolicyResolver.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt deleted file mode 100644 index f61f5b9c..00000000 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicyResolver.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.coralprotocol.coralserver.agent.execution - -import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier -import org.coralprotocol.coralserver.config.DockerConfig -import org.coralprotocol.coralserver.config.DockerTierDefaults -import org.coralprotocol.coralserver.config.SecurityConfig - -class ExecutionTrustPolicyResolver( - private val securityConfig: SecurityConfig, - private val dockerConfig: DockerConfig, -) { - fun resolve(registrySourceId: AgentRegistrySourceIdentifier): ExecutionTrustPolicy = - when (registrySourceId) { - is AgentRegistrySourceIdentifier.Local -> trustedLocalPolicy() - is AgentRegistrySourceIdentifier.Marketplace, - is AgentRegistrySourceIdentifier.Linked -> marketplacePolicy() - } - - private fun trustedLocalPolicy() = ExecutionTrustPolicy( - profileName = "trusted_local", - trustTier = ExecutionTrustTier.TRUSTED, - allowExecutableRuntime = true, - docker = dockerPolicyFor(dockerConfig.trusted, requireImageDigest = false), - ) - - private fun marketplacePolicy() = ExecutionTrustPolicy( - profileName = "marketplace_untrusted", - trustTier = ExecutionTrustTier.UNTRUSTED, - allowExecutableRuntime = securityConfig.allowMarketplaceExecutableRuntime, - docker = dockerPolicyFor( - tier = dockerConfig.marketplace, - requireImageDigest = securityConfig.requireMarketplaceDockerImageDigest, - ), - ) - - private fun dockerPolicyFor(tier: DockerTierDefaults, requireImageDigest: Boolean) = - DockerExecutionTrustPolicy( - readOnlyRootFilesystem = tier.readOnlyRootFilesystem, - noNewPrivileges = dockerConfig.noNewPrivileges, - dropCapabilities = dockerConfig.dropCapabilities, - pidsLimit = tier.pidsLimit, - nanoCpus = tier.nanoCpus, - memoryLimitBytes = tier.memoryLimitBytes, - user = tier.user, - tmpFs = tier.tmpFs, - requireImageDigest = requireImageDigest, - ) -} 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 ca0f1d34..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,8 +11,8 @@ import io.ktor.utils.io.* import kotlinx.coroutines.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.coralprotocol.coralserver.agent.execution.toHostConfig -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 @@ -42,11 +42,11 @@ data class DockerRuntime( } val docker = applicationRuntimeContext.dockerClient - val sanitisedImageName = sanitizeDockerImageName( + val sanitisedImageName = executionContext.executionPolicy.docker.sanitizeImage( imageName = image, id = executionContext.registryAgent.identifier, + profileName = executionContext.executionPolicy.profileName, logger = executionContext.logger, - requireDigest = executionContext.executionPolicy.docker.requireImageDigest ) var containerId: String? = null @@ -73,23 +73,19 @@ data class DockerRuntime( Bind(it.file.toString(), Volume(it.mountPath)) } - val hostConfig = executionContext.executionPolicy.docker.toHostConfig( - volumes = volumes, - logger = executionContext.logger - ) - val containerCreationCmd = docker.createContainerCmd(sanitisedImageName) .withName(executionContext.agent.secret) .withEnv(environment.map { (key, value) -> "$key=$value" }) - .withHostConfig(hostConfig) .withAttachStdout(true) .withAttachStderr(true) .withStopTimeout(1) .withAttachStdin(false) // Stdin makes no sense with orchestration - executionContext.executionPolicy.docker.user - ?.takeIf { it.isNotBlank() } - ?.let { containerCreationCmd.withUser(it) } + executionContext.executionPolicy.docker.applyTo( + cmd = containerCreationCmd, + volumes = volumes, + logger = executionContext.logger, + ) if (command != null) containerCreationCmd.withCmd(*command.toTypedArray()) @@ -131,7 +127,9 @@ data class DockerRuntime( if (e.rootCause is InterruptedException) throw CancellationException("Docker timeout", e) - executionContext.logger.error(e) { "Docker client error" } + executionContext.logger.error(e) { + "Docker client error for agent ${executionContext.agent.name} (image=$sanitisedImageName, container=${containerId ?: "none"})" + } throw e } finally { withContext(NonCancellable) { @@ -152,31 +150,6 @@ data class DockerRuntime( } } -internal fun sanitizeDockerImageName( - imageName: String, - id: RegistryAgentIdentifier, - logger: LoggingInterface, - requireDigest: Boolean = false, -): String { - if (imageName.contains("@sha256:")) { - return imageName - } - - if (requireDigest) { - throw IllegalArgumentException("Docker image $imageName must be pinned by digest (@sha256:...) to satisfy the active execution profile") - } - - 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/config/DockerConfig.kt b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt index 0e912098..e7aa164c 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt @@ -1,5 +1,6 @@ package org.coralprotocol.coralserver.config +import org.coralprotocol.coralserver.agent.execution.DockerExecutionTrustPolicy import org.coralprotocol.coralserver.util.isWindows import java.io.File @@ -99,22 +100,15 @@ data class DockerConfig( * @see [containerPathSeparator] */ val containerTemporaryDirectory: String = "/tmp", - val noNewPrivileges: Boolean = true, - val dropCapabilities: Set = setOf("ALL"), - val trusted: DockerTierDefaults = DockerTierDefaults(), - val marketplace: DockerTierDefaults = DockerTierDefaults( + + val trusted: DockerExecutionTrustPolicy = DockerExecutionTrustPolicy( + tmpFs = defaultDockerTmpFs(), + ), + val marketplace: DockerExecutionTrustPolicy = DockerExecutionTrustPolicy( readOnlyRootFilesystem = true, nanoCpus = 1_000_000_000, memoryLimitBytes = 512L * 1024L * 1024L, user = "65532:65532", + tmpFs = defaultDockerTmpFs(), ), ) - -data class DockerTierDefaults( - val readOnlyRootFilesystem: Boolean = false, - val pidsLimit: Long? = 256, - val nanoCpus: Long? = null, - val memoryLimitBytes: Long? = null, - val user: String? = null, - val tmpFs: Map = defaultDockerTmpFs(), -) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt b/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt index 9217adfb..2d8d38e3 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/SecurityConfig.kt @@ -7,6 +7,5 @@ data class SecurityConfig( * set it to true and understand the risks involved. */ val enableReferencedExporting: Boolean = false, - val allowMarketplaceExecutableRuntime: Boolean = false, - val requireMarketplaceDockerImageDigest: Boolean = false, + val allowUntrustedExecutableRuntime: Boolean = false, ) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt b/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt index c435019b..563d4d05 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking import org.coralprotocol.coralserver.agent.debug.* -import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicyResolver import org.coralprotocol.coralserver.agent.registry.AgentRegistry import org.coralprotocol.coralserver.config.RegistryConfig import org.coralprotocol.coralserver.mcp.McpToolManager @@ -17,8 +16,6 @@ import java.nio.file.Path const val AGENT_WATCHER_COROUTINE_SCOPE_NAME = "agentWatcherCoroutineScope" val agentModule = module { - singleOf(::ExecutionTrustPolicyResolver) - singleOf(::EchoDebugAgent) singleOf(::SeedDebugAgent) singleOf(::ToolDebugAgent) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt index 0afed932..bd3ee481 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt @@ -5,7 +5,7 @@ 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.ExecutionTrustPolicyResolver +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 @@ -15,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 @@ -44,14 +45,14 @@ class SessionAgentExecutionContext( val debugConfig by inject() val dockerConfig by inject() val llmProxyConfig by inject() - val executionTrustPolicyResolver by inject() + val securityConfig by inject() val disposableResources = mutableListOf() var lastLaunchTime: Instant? = null val executionPolicy: ExecutionTrustPolicy = - executionTrustPolicyResolver.resolve(registryAgent.identifier.registrySourceId) + 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 @@ -92,8 +93,7 @@ class SessionAgentExecutionContext( putAll(debugConfig.additionalDockerEnvironment) } - val dockerPolicy = executionPolicy.docker - if (isContainer && (dockerPolicy.readOnlyRootFilesystem || dockerPolicy.user != null)) { + if (isContainer && executionPolicy.docker.requiresWritableTmpHome) { this["HOME"] = dockerConfig.containerTemporaryDirectory this["TMPDIR"] = dockerConfig.containerTemporaryDirectory this["XDG_CACHE_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.cache" @@ -137,8 +137,6 @@ class SessionAgentExecutionContext( this["CORAL_RUNTIME_ID"] = provider.runtime.toString().lowercase() this["CORAL_REGISTRY_SOURCE"] = registryAgent.identifier.registrySourceId.toString() - this["CORAL_TRUST_TIER"] = executionPolicy.trustTier.name.lowercase() - this["CORAL_EXECUTION_PROFILE"] = executionPolicy.profileName if (agent.graphAgent.systemPrompt != null) this["CORAL_PROMPT_SYSTEM"] = agent.graphAgent.systemPrompt diff --git a/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt index 333cfa5b..aeab4fe2 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/CoralTest.kt @@ -150,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( diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt index 7bba5677..5c3c2a1d 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/DockerRuntimeTest.kt @@ -19,7 +19,9 @@ 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.ExecutionTrustPolicyResolver +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 @@ -29,9 +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.agent.execution.toHostConfig -import org.coralprotocol.coralserver.agent.runtime.sanitizeDockerImageName 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 @@ -242,11 +243,12 @@ class DockerRuntimeTest : CoralTest({ test("testDockerHostConfigHardeningDefaults") { val logger by inject(named(LOGGER_LOCAL_SESSION)) - val resolver by inject() val dockerConfig by inject() + val securityConfig by inject() - val hostConfig = resolver.resolve(AgentRegistrySourceIdentifier.Local).docker.toHostConfig(emptyList(), logger) - val tier = dockerConfig.trusted + val tier = AgentRegistrySourceIdentifier.Local + .resolveTrustPolicy(dockerConfig, securityConfig).docker + val hostConfig = tier.buildHostConfig(emptyList(), logger) hostConfig.privileged shouldBe false hostConfig.readonlyRootfs shouldBe tier.readOnlyRootFilesystem @@ -264,21 +266,22 @@ class DockerRuntimeTest : CoralTest({ version = "1.0.0", registrySourceId = AgentRegistrySourceIdentifier.Marketplace ) + val strictPolicy = DockerConfig().marketplace.copy(requireImageDigest = true) shouldThrow { - sanitizeDockerImageName( + strictPolicy.sanitizeImage( imageName = "ghcr.io/coral-protocol/agent:1.0.0", id = identifier, + profileName = "marketplace_untrusted", logger = logger, - requireDigest = true ) } - sanitizeDockerImageName( + strictPolicy.sanitizeImage( imageName = "ghcr.io/coral-protocol/agent@sha256:abc123", id = identifier, + profileName = "marketplace_untrusted", logger = logger, - requireDigest = true ) shouldBe "ghcr.io/coral-protocol/agent@sha256:abc123" } diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt index 017df23c..d0bd5cbb 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt @@ -2,8 +2,8 @@ package org.coralprotocol.coralserver.session import io.kotest.matchers.shouldBe import org.coralprotocol.coralserver.CoralTest -import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicyResolver import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier +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 @@ -11,75 +11,56 @@ import org.koin.test.inject class ExecutionTrustPolicyResolverTest : CoralTest({ test("testLocalTrustPolicyMirrorsTrustedTierConfig") { - val resolver by inject() val dockerConfig by inject() + val securityConfig by inject() - val policy = resolver.resolve(AgentRegistrySourceIdentifier.Local) - val tier = dockerConfig.trusted + val policy = AgentRegistrySourceIdentifier.Local.resolveTrustPolicy(dockerConfig, securityConfig) policy.profileName shouldBe "trusted_local" policy.trustTier shouldBe ExecutionTrustTier.TRUSTED policy.allowExecutableRuntime shouldBe true - policy.docker.requireImageDigest shouldBe false - policy.docker.readOnlyRootFilesystem shouldBe tier.readOnlyRootFilesystem - policy.docker.pidsLimit shouldBe tier.pidsLimit - policy.docker.nanoCpus shouldBe tier.nanoCpus - policy.docker.memoryLimitBytes shouldBe tier.memoryLimitBytes - policy.docker.user shouldBe tier.user - policy.docker.tmpFs shouldBe tier.tmpFs + policy.docker shouldBe dockerConfig.trusted } test("testMarketplaceTrustPolicyMirrorsMarketplaceTierConfig") { - val resolver by inject() val dockerConfig by inject() + val securityConfig by inject() - val policy = resolver.resolve(AgentRegistrySourceIdentifier.Marketplace) - val tier = dockerConfig.marketplace + val policy = AgentRegistrySourceIdentifier.Marketplace.resolveTrustPolicy(dockerConfig, securityConfig) policy.profileName shouldBe "marketplace_untrusted" policy.trustTier shouldBe ExecutionTrustTier.UNTRUSTED policy.allowExecutableRuntime shouldBe false - policy.docker.requireImageDigest shouldBe false - policy.docker.readOnlyRootFilesystem shouldBe tier.readOnlyRootFilesystem - policy.docker.pidsLimit shouldBe tier.pidsLimit - policy.docker.nanoCpus shouldBe tier.nanoCpus - policy.docker.memoryLimitBytes shouldBe tier.memoryLimitBytes - policy.docker.user shouldBe tier.user - policy.docker.tmpFs shouldBe tier.tmpFs + policy.docker shouldBe dockerConfig.marketplace } test("testLinkedTrustPolicyInheritsMarketplaceHardening") { - val resolver by inject() + val dockerConfig by inject() + val securityConfig by inject() - val linked = resolver.resolve(AgentRegistrySourceIdentifier.Linked("peer-server")) - val marketplace = resolver.resolve(AgentRegistrySourceIdentifier.Marketplace) + val linked = AgentRegistrySourceIdentifier.Linked("peer-server") + .resolveTrustPolicy(dockerConfig, securityConfig) + val marketplace = AgentRegistrySourceIdentifier.Marketplace + .resolveTrustPolicy(dockerConfig, securityConfig) linked shouldBe marketplace } - test("testOperatorCanUnblockMarketplaceExecutableRuntime") { + test("testOperatorCanUnblockUntrustedExecutableRuntime") { val dockerConfig by inject() + val permissive = SecurityConfig(allowUntrustedExecutableRuntime = true) - val permissiveResolver = ExecutionTrustPolicyResolver( - securityConfig = SecurityConfig(allowMarketplaceExecutableRuntime = true), - dockerConfig = dockerConfig, - ) - - permissiveResolver.resolve(AgentRegistrySourceIdentifier.Marketplace) + AgentRegistrySourceIdentifier.Marketplace.resolveTrustPolicy(dockerConfig, permissive) .allowExecutableRuntime shouldBe true } test("testOperatorCanRequireMarketplaceDockerImageDigest") { - val dockerConfig by inject() - - val strictResolver = ExecutionTrustPolicyResolver( - securityConfig = SecurityConfig(requireMarketplaceDockerImageDigest = true), - dockerConfig = dockerConfig, - ) + val securityConfig by inject() + val strict = DockerConfig(marketplace = DockerConfig().marketplace.copy(requireImageDigest = true)) - strictResolver.resolve(AgentRegistrySourceIdentifier.Marketplace) + AgentRegistrySourceIdentifier.Marketplace.resolveTrustPolicy(strict, securityConfig) .docker.requireImageDigest shouldBe true - strictResolver.resolve(AgentRegistrySourceIdentifier.Local) + 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 8f250f2c..8cee76d3 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt @@ -29,6 +29,7 @@ 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 @@ -36,6 +37,7 @@ import org.coralprotocol.coralserver.CoralTest import org.coralprotocol.coralserver.agent.debug.SeedDebugAgent import org.coralprotocol.coralserver.agent.debug.ToolDebugAgent import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier +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 @@ -240,61 +242,47 @@ class SessionApiTest : CoralTest({ test("testSessionApiExposesExecutionTrustPosture") { val client by inject() - val registry by inject() - - val postureName = "posture-agent" - val postureVersion = "1.0.0" - val postureIdentifier = RegistryAgentIdentifier( - postureName, - postureVersion, - AgentRegistrySourceIdentifier.Local - ) + val localSessionManager by inject() - registry.sources.add( - ListAgentRegistrySource( - "posture agents", - listOf( - registryAgent(postureName) { - version = postureVersion - runtime(FunctionRuntime()) - registrySourceId = AgentRegistrySourceIdentifier.Local + val (session, namespace) = localSessionManager.createSession( + namespaceName = namespaceName, + agentGraph = AgentGraph( + agents = mapOf( + graphAgentPair("local-agent") { + registryAgent { + registrySourceId = AgentRegistrySourceIdentifier.Local + runtime(FunctionRuntime()) + } + provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) + }, + graphAgentPair("marketplace-agent") { + registryAgent { + registrySourceId = AgentRegistrySourceIdentifier.Marketplace + runtime(FunctionRuntime()) + } + provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) } ) ) ) - val sessionId: SessionIdentifier = client.authenticatedPost(LocalSessions.Session()) { - setBody( - sessionRequest { - agentGraphRequest { - agent(postureIdentifier) { - provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) - } - isolateAllAgents() - } - createNamespaceIfNotExists { - name = namespaceName - } - immediateExecution { - persistenceMode = SessionPersistenceMode.HoldAfterExit(1000) - } - } - ) - }.shouldBeOK().body() - val state: SessionStateExtended = client.authenticatedGet( LocalSessions.Session.Existing.Extended( LocalSessions.Session.Existing( - namespace = sessionId.namespace, - sessionId = sessionId.sessionId + namespace = namespace.name, + sessionId = session.id ) ) ).shouldBeOK().body() - val agentState = state.agents.single { it.name == postureName } - agentState.executionProfile shouldBe "trusted_local" - agentState.trustTier shouldBe ExecutionTrustTier.TRUSTED + val byName = state.agents.associateBy { it.name } + byName.getValue("local-agent").executionProfile shouldBe "trusted_local" + byName.getValue("local-agent").trustTier shouldBe ExecutionTrustTier.TRUSTED + byName.getValue("marketplace-agent").executionProfile shouldBe "marketplace_untrusted" + byName.getValue("marketplace-agent").trustTier shouldBe ExecutionTrustTier.UNTRUSTED + + session.sessionScope.cancel() } test("testSessionPersistence") { From b8c12df365eff0d30169128ac45d2b35476b32e6 Mon Sep 17 00:00:00 2001 From: Andri Date: Mon, 11 May 2026 11:03:35 +0100 Subject: [PATCH 5/8] chore: document hardening defaults and env-var contract --- .../agent/execution/ExecutionTrustPolicy.kt | 1 + .../coralserver/config/DockerConfig.kt | 19 ++++++++++++++++--- .../coralserver/modules/AgentModule.kt | 2 +- .../session/SessionAgentExecutionContext.kt | 6 ++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt index c5dab537..beb70c2a 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt @@ -21,6 +21,7 @@ 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, diff --git a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt index e7aa164c..a6ba63e3 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/config/DockerConfig.kt @@ -47,6 +47,9 @@ 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" ) @@ -101,14 +104,24 @@ data class DockerConfig( */ 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, - memoryLimitBytes = 512L * 1024L * 1024L, - user = "65532:65532", + 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/modules/AgentModule.kt b/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt index 563d4d05..cad33e9d 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/modules/AgentModule.kt @@ -71,4 +71,4 @@ val agentModule = module { single(named(AGENT_WATCHER_COROUTINE_SCOPE_NAME)) { CoroutineScope(Dispatchers.IO) } -} +} \ 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 bd3ee481..41b50b78 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt @@ -93,6 +93,10 @@ 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 @@ -136,6 +140,8 @@ class SessionAgentExecutionContext( this["CORAL_API_URL"] = applicationRuntimeContext.getApiUrl(addressConsumer).toString() this["CORAL_RUNTIME_ID"] = provider.runtime.toString().lowercase() + // Trust posture of the agent's registry source: "local", "marketplace", or "linked()". + // Agents can use this to gate any tier-aware behaviour they want to apply. this["CORAL_REGISTRY_SOURCE"] = registryAgent.identifier.registrySourceId.toString() if (agent.graphAgent.systemPrompt != null) From 34ae39d27b9ae73781b5cc358768ddcdff7b6001 Mon Sep 17 00:00:00 2001 From: Andri Date: Mon, 11 May 2026 13:06:05 +0100 Subject: [PATCH 6/8] chore: drop redundant executionProfile/trustTier state fields --- .../agent/execution/ExecutionTrustPolicy.kt | 2 + .../agent/registry/AgentRegistrySource.kt | 8 ++++ .../coralserver/session/SessionAgent.kt | 2 - .../session/state/SessionAgentState.kt | 7 --- .../coralserver/session/SessionApiTest.kt | 46 ------------------- 5 files changed, 10 insertions(+), 55 deletions(-) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt index beb70c2a..c01ca4bd 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt @@ -44,6 +44,8 @@ data class ExecutionTrustPolicy( } } +// Authoritative source → trust-tier mapping for Stage 1. Local is trusted; Marketplace and Linked are untrusted. +// Stage 2 introduces declared-intent and runtime-aware overrides; both should plug in here. fun AgentRegistrySourceIdentifier.resolveTrustPolicy( dockerConfig: DockerConfig, securityConfig: SecurityConfig, 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/session/SessionAgent.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt index 5897ae2f..4eb18833 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgent.kt @@ -571,8 +571,6 @@ class SessionAgent( description = description, links = links.map { it.name }.toSet(), annotations = graphAgent.annotations, - executionProfile = executionContext.executionPolicy.profileName, - trustTier = executionContext.executionPolicy.trustTier, ) /** 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 20e99b43..1e95d147 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/state/SessionAgentState.kt @@ -2,7 +2,6 @@ package org.coralprotocol.coralserver.session.state import io.github.smiley4.schemakenerator.core.annotations.Description import kotlinx.serialization.Serializable -import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier import org.coralprotocol.coralserver.agent.graph.UniqueAgentName import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier import org.coralprotocol.coralserver.llmproxy.TokenUsage @@ -29,12 +28,6 @@ data class SessionAgentState( override val annotations: Map, - @Description("Resolved execution profile applied to this agent") - val executionProfile: String, - - @Description("Resolved trust tier applied to this agent") - val trustTier: ExecutionTrustTier, - @Description("Token usage broken down by provider/model (e.g. 'openai/gpt-4.1')") val tokensByModel: Map = emptyMap(), ) : SessionResource diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt index 8cee76d3..7d594c56 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/SessionApiTest.kt @@ -36,7 +36,6 @@ 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.execution.ExecutionTrustTier import org.coralprotocol.coralserver.agent.graph.AgentGraph import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider import org.coralprotocol.coralserver.agent.graph.GraphAgentTool @@ -240,51 +239,6 @@ class SessionApiTest : CoralTest({ localSessionManager.waitAllSessions() } - test("testSessionApiExposesExecutionTrustPosture") { - val client by inject() - val localSessionManager by inject() - - val (session, namespace) = localSessionManager.createSession( - namespaceName = namespaceName, - agentGraph = AgentGraph( - agents = mapOf( - graphAgentPair("local-agent") { - registryAgent { - registrySourceId = AgentRegistrySourceIdentifier.Local - runtime(FunctionRuntime()) - } - provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) - }, - graphAgentPair("marketplace-agent") { - registryAgent { - registrySourceId = AgentRegistrySourceIdentifier.Marketplace - runtime(FunctionRuntime()) - } - provider = GraphAgentProvider.Local(RuntimeId.FUNCTION) - } - ) - ) - ) - - val state: SessionStateExtended = - client.authenticatedGet( - LocalSessions.Session.Existing.Extended( - LocalSessions.Session.Existing( - namespace = namespace.name, - sessionId = session.id - ) - ) - ).shouldBeOK().body() - - val byName = state.agents.associateBy { it.name } - byName.getValue("local-agent").executionProfile shouldBe "trusted_local" - byName.getValue("local-agent").trustTier shouldBe ExecutionTrustTier.TRUSTED - byName.getValue("marketplace-agent").executionProfile shouldBe "marketplace_untrusted" - byName.getValue("marketplace-agent").trustTier shouldBe ExecutionTrustTier.UNTRUSTED - - session.sessionScope.cancel() - } - test("testSessionPersistence") { val client by inject() From af5e43b0d8fca3da1a3de11d18f21c8f8c375497 Mon Sep 17 00:00:00 2001 From: Andri Date: Mon, 11 May 2026 13:17:08 +0100 Subject: [PATCH 7/8] chore: drop ExecutionTrustTier enum --- .../agent/execution/ExecutionTrustPolicy.kt | 24 +++++-------------- .../ExecutionTrustPolicyResolverTest.kt | 3 --- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt index c01ca4bd..e5e8084f 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/agent/execution/ExecutionTrustPolicy.kt @@ -4,19 +4,12 @@ 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 kotlinx.serialization.Serializable 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 -@Serializable -enum class ExecutionTrustTier { - TRUSTED, - UNTRUSTED, -} - data class DockerExecutionTrustPolicy( val readOnlyRootFilesystem: Boolean = false, val noNewPrivileges: Boolean = true, @@ -34,30 +27,25 @@ data class DockerExecutionTrustPolicy( } data class ExecutionTrustPolicy( - val trustTier: ExecutionTrustTier, + val profileName: String, val allowExecutableRuntime: Boolean, val docker: DockerExecutionTrustPolicy, -) { - val profileName: String = when (trustTier) { - ExecutionTrustTier.TRUSTED -> "trusted_local" - ExecutionTrustTier.UNTRUSTED -> "marketplace_untrusted" - } -} +) -// Authoritative source → trust-tier mapping for Stage 1. Local is trusted; Marketplace and Linked are untrusted. -// Stage 2 introduces declared-intent and runtime-aware overrides; both should plug in here. +// 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( - trustTier = ExecutionTrustTier.TRUSTED, + profileName = "trusted_local", allowExecutableRuntime = true, docker = dockerConfig.trusted, ) is AgentRegistrySourceIdentifier.Marketplace, is AgentRegistrySourceIdentifier.Linked -> ExecutionTrustPolicy( - trustTier = ExecutionTrustTier.UNTRUSTED, + profileName = "marketplace_untrusted", allowExecutableRuntime = securityConfig.allowUntrustedExecutableRuntime, docker = dockerConfig.marketplace, ) diff --git a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt index d0bd5cbb..08375ab8 100644 --- a/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt +++ b/src/test/kotlin/org/coralprotocol/coralserver/session/ExecutionTrustPolicyResolverTest.kt @@ -2,7 +2,6 @@ package org.coralprotocol.coralserver.session import io.kotest.matchers.shouldBe import org.coralprotocol.coralserver.CoralTest -import org.coralprotocol.coralserver.agent.execution.ExecutionTrustTier import org.coralprotocol.coralserver.agent.execution.resolveTrustPolicy import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier import org.coralprotocol.coralserver.config.DockerConfig @@ -17,7 +16,6 @@ class ExecutionTrustPolicyResolverTest : CoralTest({ val policy = AgentRegistrySourceIdentifier.Local.resolveTrustPolicy(dockerConfig, securityConfig) policy.profileName shouldBe "trusted_local" - policy.trustTier shouldBe ExecutionTrustTier.TRUSTED policy.allowExecutableRuntime shouldBe true policy.docker shouldBe dockerConfig.trusted } @@ -29,7 +27,6 @@ class ExecutionTrustPolicyResolverTest : CoralTest({ val policy = AgentRegistrySourceIdentifier.Marketplace.resolveTrustPolicy(dockerConfig, securityConfig) policy.profileName shouldBe "marketplace_untrusted" - policy.trustTier shouldBe ExecutionTrustTier.UNTRUSTED policy.allowExecutableRuntime shouldBe false policy.docker shouldBe dockerConfig.marketplace } From 6122bce14c479b1777f43ab98deca8c166c90db0 Mon Sep 17 00:00:00 2001 From: Andri Date: Mon, 11 May 2026 13:40:47 +0100 Subject: [PATCH 8/8] chore: drop CORAL_REGISTRY_SOURCE env var --- .../coralserver/session/SessionAgentExecutionContext.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt index 41b50b78..9d1392d0 100644 --- a/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt +++ b/src/main/kotlin/org/coralprotocol/coralserver/session/SessionAgentExecutionContext.kt @@ -140,10 +140,6 @@ class SessionAgentExecutionContext( this["CORAL_API_URL"] = applicationRuntimeContext.getApiUrl(addressConsumer).toString() this["CORAL_RUNTIME_ID"] = provider.runtime.toString().lowercase() - // Trust posture of the agent's registry source: "local", "marketplace", or "linked()". - // Agents can use this to gate any tier-aware behaviour they want to apply. - this["CORAL_REGISTRY_SOURCE"] = registryAgent.identifier.registrySourceId.toString() - if (agent.graphAgent.systemPrompt != null) this["CORAL_PROMPT_SYSTEM"] = agent.graphAgent.systemPrompt