Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package org.coralprotocol.coralserver.agent.execution

import com.github.dockerjava.api.command.CreateContainerCmd
import com.github.dockerjava.api.model.Bind
import com.github.dockerjava.api.model.Capability
import com.github.dockerjava.api.model.HostConfig
import org.coralprotocol.coralserver.agent.registry.AgentRegistrySourceIdentifier
import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier
import org.coralprotocol.coralserver.config.DockerConfig
import org.coralprotocol.coralserver.config.SecurityConfig
import org.coralprotocol.coralserver.logging.LoggingInterface

data class DockerExecutionTrustPolicy(
val readOnlyRootFilesystem: Boolean = false,
val noNewPrivileges: Boolean = true,
val dropCapabilities: Set<String> = setOf("ALL"),
// bounds fork-bomb blast radius; 256 PIDs covers typical agent process counts
val pidsLimit: Long? = 256,
val nanoCpus: Long? = null,
val memoryLimitBytes: Long? = null,
val user: String? = null,
val tmpFs: Map<String, String> = emptyMap(),
val requireImageDigest: Boolean = false,
) {
val requiresWritableTmpHome: Boolean
get() = readOnlyRootFilesystem || user != null
}

data class ExecutionTrustPolicy(
val profileName: String,
val allowExecutableRuntime: Boolean,
val docker: DockerExecutionTrustPolicy,
)

// Authoritative source → docker hardening profile mapping for Stage 1. Local is trusted; Marketplace and Linked
// share the marketplace profile. Stage 2 will plug in declared-intent and runtime-aware overrides here.
fun AgentRegistrySourceIdentifier.resolveTrustPolicy(
dockerConfig: DockerConfig,
securityConfig: SecurityConfig,
): ExecutionTrustPolicy = when (this) {
is AgentRegistrySourceIdentifier.Local -> ExecutionTrustPolicy(
profileName = "trusted_local",
allowExecutableRuntime = true,
docker = dockerConfig.trusted,
)
is AgentRegistrySourceIdentifier.Marketplace,
is AgentRegistrySourceIdentifier.Linked -> ExecutionTrustPolicy(
profileName = "marketplace_untrusted",
allowExecutableRuntime = securityConfig.allowUntrustedExecutableRuntime,
docker = dockerConfig.marketplace,
)
}

fun DockerExecutionTrustPolicy.buildHostConfig(
volumes: List<Bind>,
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<Bind>,
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<String>.toCapabilities(logger: LoggingInterface): List<Capability> =
mapNotNull { capability ->
runCatching { enumValueOf<Capability>(capability.uppercase()) }
.onFailure { logger.warn { "Unknown Docker capability in config: $capability" } }
.getOrNull()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import io.ktor.utils.io.*
import kotlinx.coroutines.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.coralprotocol.coralserver.agent.registry.RegistryAgentIdentifier
import org.coralprotocol.coralserver.agent.execution.applyTo
import org.coralprotocol.coralserver.agent.execution.sanitizeImage
import org.coralprotocol.coralserver.events.SessionEvent
import org.coralprotocol.coralserver.logging.LoggingInterface
import org.coralprotocol.coralserver.logging.LoggingTag
Expand Down Expand Up @@ -41,8 +42,12 @@ data class DockerRuntime(
}

val docker = applicationRuntimeContext.dockerClient
val sanitisedImageName =
docker.sanitizeDockerImageName(image, executionContext.registryAgent.identifier, executionContext.logger)
val sanitisedImageName = executionContext.executionPolicy.docker.sanitizeImage(
imageName = image,
id = executionContext.registryAgent.identifier,
profileName = executionContext.executionPolicy.profileName,
logger = executionContext.logger,
)
var containerId: String? = null

try {
Expand Down Expand Up @@ -71,12 +76,17 @@ data class DockerRuntime(
val containerCreationCmd = docker.createContainerCmd(sanitisedImageName)
.withName(executionContext.agent.secret)
.withEnv(environment.map { (key, value) -> "$key=$value" })
.withHostConfig(HostConfig().withBinds(volumes))
.withAttachStdout(true)
.withAttachStderr(true)
.withStopTimeout(1)
.withAttachStdin(false) // Stdin makes no sense with orchestration

executionContext.executionPolicy.docker.applyTo(
cmd = containerCreationCmd,
volumes = volumes,
logger = executionContext.logger,
)

if (command != null)
containerCreationCmd.withCmd(*command.toTypedArray())

Expand Down Expand Up @@ -116,6 +126,11 @@ data class DockerRuntime(
@OptIn(InternalAPI::class)
if (e.rootCause is InterruptedException)
throw CancellationException("Docker timeout", e)

executionContext.logger.error(e) {
"Docker client error for agent ${executionContext.agent.name} (image=$sanitisedImageName, container=${containerId ?: "none"})"
}
throw e
} finally {
withContext(NonCancellable) {
when (val containerId = containerId) {
Expand All @@ -135,22 +150,6 @@ data class DockerRuntime(
}
}

private fun DockerClient.sanitizeDockerImageName(
imageName: String,
id: RegistryAgentIdentifier,
logger: LoggingInterface
): String {
if (imageName.contains(":")) {
if (!imageName.endsWith(":${id.version}")) {
logger.warn { "Image $imageName does not match the agent version: ${id.version}" }
}

return imageName
} else {
return "$imageName:${id.version}"
}
}

private fun DockerClient.findImage(imageName: String): Image? =
listImagesCmd()
.exec()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ data class ExecutableRuntime(
executionContext: SessionAgentExecutionContext,
applicationRuntimeContext: ApplicationRuntimeContext
) {
if (!executionContext.executionPolicy.allowExecutableRuntime) {
val message =
"Executable runtime is disabled by execution profile '${executionContext.executionPolicy.profileName}'"
executionContext.logger.error { message }
throw IllegalStateException(message)
}

val potentialPaths = buildList {
// on Windows, if given a path without an extension, try .exe, .cmd and .bat files
// on Linux it is expected that a marks files as executables and uses the appropriate shebang to achieve
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package org.coralprotocol.coralserver.config

import com.sksamuel.hoplite.ConfigAlias
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.coralprotocol.coralserver.agent.execution.DockerExecutionTrustPolicy
import org.coralprotocol.coralserver.util.isWindows
import java.io.File

Expand Down Expand Up @@ -49,6 +47,13 @@ private fun defaultDockerSocket(): String {
}
}

// 64 MiB tmpfs scratch for the agent's writable temp dir. Flags block privilege-escalation via the writable
// mount (noexec, nosuid, nodev); size is small enough to bound abuse but large enough for typical caches,
// sockets, and small file-transport option payloads.
private fun defaultDockerTmpFs(): Map<String, String> = mapOf(
"/tmp" to "rw,noexec,nosuid,nodev,size=64m"
)

data class DockerConfig(
/**
* Optional docker socket path
Expand Down Expand Up @@ -97,5 +102,26 @@ data class DockerConfig(
*
* @see [containerPathSeparator]
*/
val containerTemporaryDirectory: String = "/tmp"
)
val containerTemporaryDirectory: String = "/tmp",

/**
* Container execution profile for locally-registered (trusted) agents. Permissive by default — the operator
* ships and runs their own code here, so only the tmpfs scratch is locked down.
*/
val trusted: DockerExecutionTrustPolicy = DockerExecutionTrustPolicy(
tmpFs = defaultDockerTmpFs(),
),

/**
* Container execution profile for marketplace and linked-source (untrusted) agents. Read-only rootfs,
* distroless 'nonroot' UID, ~1 vCPU and 512 MiB. The image MUST ship the 65532 UID/GID — agents that
* require root will fail at container start.
*/
val marketplace: DockerExecutionTrustPolicy = DockerExecutionTrustPolicy(
readOnlyRootFilesystem = true,
nanoCpus = 1_000_000_000, // Docker nano-CPU units: 1e9 == 1 vCPU
memoryLimitBytes = 512L * 1024L * 1024L, // 512 MiB
user = "65532:65532", // distroless 'nonroot' UID/GID
tmpFs = defaultDockerTmpFs(),
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ data class SecurityConfig(
* set it to true and understand the risks involved.
*/
val enableReferencedExporting: Boolean = false,
)
val allowUntrustedExecutableRuntime: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ class SessionAgent(
status = status.value,
description = description,
links = links.map { it.name }.toSet(),
annotations = graphAgent.annotations
annotations = graphAgent.annotations,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,10 +16,21 @@ sealed interface SessionAgentDisposableResource {
val mountPath = "${dockerConfig.containerTemporaryDirectory}${dockerConfig.containerNameSeparator}${file.name}"
init {
file.writeBytes(data)
try {
Files.setPosixFilePermissions(
file,
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.GROUP_READ,
PosixFilePermission.OTHERS_READ,
)
)
} catch (_: UnsupportedOperationException) {
}
}

override fun dispose() {
deleteFile(file)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package org.coralprotocol.coralserver.session

import io.ktor.utils.io.*
import kotlinx.coroutines.flow.update
import org.coralprotocol.coralserver.agent.execution.ExecutionTrustPolicy
import org.coralprotocol.coralserver.agent.execution.resolveTrustPolicy
import org.coralprotocol.coralserver.agent.graph.GraphAgentProvider
import org.coralprotocol.coralserver.agent.registry.option.*
import org.coralprotocol.coralserver.agent.runtime.ApplicationRuntimeContext
Expand All @@ -13,6 +15,7 @@ import org.coralprotocol.coralserver.config.AddressConsumer
import org.coralprotocol.coralserver.config.DebugConfig
import org.coralprotocol.coralserver.config.DockerConfig
import org.coralprotocol.coralserver.config.LlmProxyConfig
import org.coralprotocol.coralserver.config.SecurityConfig
import org.coralprotocol.coralserver.events.SessionEvent
import org.coralprotocol.coralserver.mcp.McpTransportType
import org.coralprotocol.coralserver.session.reporting.SessionAgentUsageReport
Expand Down Expand Up @@ -42,11 +45,15 @@ class SessionAgentExecutionContext(
val debugConfig by inject<DebugConfig>()
val dockerConfig by inject<DockerConfig>()
val llmProxyConfig by inject<LlmProxyConfig>()
val securityConfig by inject<SecurityConfig>()

val disposableResources = mutableListOf<SessionAgentDisposableResource>()

var lastLaunchTime: Instant? = null

val executionPolicy: ExecutionTrustPolicy =
registryAgent.identifier.registrySourceId.resolveTrustPolicy(dockerConfig, securityConfig)

/**
* A list of usage reports for this agent. When a session ends, all usage reports for each agent will be sent to
* webhooks, if configured.
Expand Down Expand Up @@ -86,6 +93,18 @@ class SessionAgentExecutionContext(
putAll(debugConfig.additionalDockerEnvironment)
}

// Read-only rootfs + non-root UID (e.g. distroless 'nonroot' UID 65532) leaves the agent without a
// writable HOME and without a /etc/passwd entry, so libraries that derive paths via getpwuid() land
// on /nonexistent. Redirect HOME/TMPDIR/XDG_* into the tmpfs scratch so caches and config writes
// succeed without giving the agent write access to the rootfs.
if (isContainer && executionPolicy.docker.requiresWritableTmpHome) {
this["HOME"] = dockerConfig.containerTemporaryDirectory
this["TMPDIR"] = dockerConfig.containerTemporaryDirectory
this["XDG_CACHE_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.cache"
this["XDG_CONFIG_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.config"
this["XDG_DATA_HOME"] = "${dockerConfig.containerTemporaryDirectory}/.local/share"
}

// User options
options.forEach { (name, value) ->
when (value.option().transport) {
Expand Down
Loading
Loading