chore(audit): tool path resolution, config file, dead code removal, static analysis cleanup

Tool fixes:
- FileWriteTool, FileEditTool: resolve relative paths against workingDir instead of JVM cwd
- FileReadTool: remove allowedPaths restriction — reads are unrestricted
- ShellTool: add workingDir for ProcessBuilder, remove environment().clear(), fail-open when allowedExecutables empty

Config:
- Add [tools] section to config.toml: sandbox_root, working_dir, shell_allowed_executables, default_system_prompt_path
- Three-level resolution: env var > config file > hardcoded default
- OrchestrationConfig sandboxRoot and defaultSystemPromptPath now sourced from CorrexConfig
- Single defaultOrchestrationConfig in ServerModule, shared by SessionRoutes and GlobalStreamHandler

Dead code:
- Delete EventTypes.kt — orphaned string constants duplicating serialization annotations

Static analysis:
- Remove 10 unused imports across 9 files
- Remove redundant return in ReplayOrchestrator
- Remove redundant suspend from ApprovalCoordinator.handleResponse
- Fix unreachable InputMode.None branch in InputBar
- Enable full detekt rule sets in detekt.yml
This commit is contained in:
2026-05-19 12:48:47 +04:00
parent c0efa0ef03
commit 71a73a4fa2
30 changed files with 253 additions and 94 deletions
+1
View File
@@ -9,6 +9,7 @@ application {
} }
dependencies { dependencies {
implementation project(':core:config')
implementation project(':core:events') implementation project(':core:events')
implementation project(':core:approvals') implementation project(':core:approvals')
implementation project(':core:sessions') implementation project(':core:sessions')
@@ -20,22 +20,28 @@ import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.transitions.evaluation.PromptResolver
import com.correx.core.transitions.resolution.DefaultTransitionResolver import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.artifact.ArtifactPayloadValidator import com.correx.core.validation.artifact.ArtifactPayloadValidator
import com.correx.core.validation.pipeline.ValidationPipeline import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.apps.server.logging.LoggingEventStore import com.correx.apps.server.logging.LoggingEventStore
import com.correx.core.artifactstore.ArtifactStore
import com.correx.infrastructure.InfrastructureModule import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter
import com.correx.infrastructure.inference.DefaultProviderRegistry import com.correx.infrastructure.inference.DefaultProviderRegistry
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
import com.correx.core.config.ConfigLoader
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileWriteConfig import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig import com.correx.infrastructure.tools.ToolConfig
import com.correx.core.events.EventDispatcher import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main") private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
@@ -46,8 +52,18 @@ fun main() {
val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(buildLlamaProvider())) val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(buildLlamaProvider()))
val repositories = buildRepositories(eventStore) val repositories = buildRepositories(eventStore)
val approvalEngine = DefaultApprovalEngine() val approvalEngine = DefaultApprovalEngine()
val sandboxRoot = Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox") val correxConfig = ConfigLoader.load()
val toolRegistry = InfrastructureModule.createToolRegistry(buildToolConfig(artifactStore, sandboxRoot)) val toolsConfig = correxConfig.tools
val sandboxRoot = System.getenv("CORREX_SANDBOX_ROOT")
?.let { Path.of(it) }
?: toolsConfig.sandboxRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox")
val workingDir = System.getenv("CORREX_WORKING_DIR")
?.let { Path.of(it) }
?: toolsConfig.workingDir.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Path.of("").toAbsolutePath()
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
val toolRegistry = InfrastructureModule.createToolRegistry(buildToolConfig(artifactStore, sandboxRoot, workingDir, shellAllowedExecutables))
val toolExecutor = InfrastructureModule.createToolExecutor( val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry, registry = toolRegistry,
eventDispatcher = EventDispatcher(eventStore), eventDispatcher = EventDispatcher(eventStore),
@@ -70,6 +86,10 @@ fun main() {
retryCoordinator = DefaultRetryCoordinator(eventStore), retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore, artifactStore = artifactStore,
) )
val defaultOrchestrationConfig = OrchestrationConfig(
sandboxRoot = sandboxRoot,
defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath,
)
val module = ServerModule( val module = ServerModule(
orchestrator = orchestrator, orchestrator = orchestrator,
eventStore = eventStore, eventStore = eventStore,
@@ -77,6 +97,7 @@ fun main() {
sessionRepository = repositories.sessionRepository, sessionRepository = repositories.sessionRepository,
workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()), workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()),
providerRegistry = infraRegistry.asServerRegistry(), providerRegistry = infraRegistry.asServerRegistry(),
defaultOrchestrationConfig = defaultOrchestrationConfig,
) )
val modelId = System.getenv("CORREX_MODEL_ID") ?: "default" val modelId = System.getenv("CORREX_MODEL_ID") ?: "default"
val modelPath = System.getenv("CORREX_MODEL_PATH") ?: "(not set)" val modelPath = System.getenv("CORREX_MODEL_PATH") ?: "(not set)"
@@ -87,6 +108,10 @@ fun main() {
log.info(" model path : {}", modelPath) log.info(" model path : {}", modelPath)
log.info(" llama url : {}", llamaUrl) log.info(" llama url : {}", llamaUrl)
log.info(" sandbox root : {}", sandboxRoot) log.info(" sandbox root : {}", sandboxRoot)
log.info(" working dir : {}", workingDir)
if (shellAllowedExecutables.isEmpty()) {
log.warn(" shell tool : no executable allowlist configured — all executables allowed")
}
log.info(" event store : {}", eventStore::class.simpleName) log.info(" event store : {}", eventStore::class.simpleName)
log.info(" artifact store: {}", artifactStore::class.simpleName) log.info(" artifact store: {}", artifactStore::class.simpleName)
log.info(" tools : {}", toolRegistry.all().map { it.name }) log.info(" tools : {}", toolRegistry.all().map { it.name })
@@ -96,15 +121,15 @@ fun main() {
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true) embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
} }
private fun buildLlamaProvider() = InfrastructureModule.createLlamaCppProvider( private fun buildLlamaProvider(): LlamaCppInferenceProvider = InfrastructureModule.createLlamaCppProvider(
modelId = System.getenv("CORREX_MODEL_ID") ?: "default", modelId = System.getenv("CORREX_MODEL_ID") ?: "default",
modelPath = System.getenv("CORREX_MODEL_PATH") ?: "", modelPath = System.getenv("CORREX_MODEL_PATH") ?: "",
baseUrl = System.getenv("CORREX_LLAMA_URL") ?: "http://127.0.0.1:10000", baseUrl = System.getenv("CORREX_LLAMA_URL") ?: "http://127.0.0.1:10000",
) )
private fun buildRepositories( private fun buildRepositories(
eventStore: com.correx.core.events.stores.EventStore, eventStore: EventStore,
) = OrchestratorRepositories( ): OrchestratorRepositories = OrchestratorRepositories(
eventStore = eventStore, eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())), inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository( orchestrationRepository = OrchestrationRepository(
@@ -117,15 +142,28 @@ private fun buildRepositories(
) )
private fun buildToolConfig( private fun buildToolConfig(
artifactStore: com.correx.core.artifactstore.ArtifactStore, artifactStore: ArtifactStore,
sandboxRoot: java.nio.file.Path, sandboxRoot: Path,
) = ToolConfig( workingDir: Path,
shell = ShellConfig(enabled = true), shellAllowedExecutables: Set<String>,
): ToolConfig = ToolConfig(
shell = ShellConfig(
enabled = true,
allowedExecutables = shellAllowedExecutables,
workingDir = workingDir,
),
fileWrite = FileWriteConfig( fileWrite = FileWriteConfig(
allowedPaths = setOf(sandboxRoot, workingDir),
enabled = true, enabled = true,
artifactStore = artifactStore, artifactStore = artifactStore,
materializingWriter = DefaultMaterializingArtifactWriter(), materializingWriter = DefaultMaterializingArtifactWriter(),
sandboxRoot = sandboxRoot, sandboxRoot = sandboxRoot,
workingDir = workingDir,
),
fileEdit = FileEditConfig(
enabled = true,
allowedPaths = setOf(sandboxRoot, workingDir),
workingDir = workingDir,
), ),
) )
@@ -5,6 +5,7 @@ import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.DefaultSessionRepository
data class ServerModule( data class ServerModule(
@@ -14,4 +15,5 @@ data class ServerModule(
val sessionRepository: DefaultSessionRepository, val sessionRepository: DefaultSessionRepository,
val workflowRegistry: WorkflowRegistry, val workflowRegistry: WorkflowRegistry,
val providerRegistry: ProviderRegistry, val providerRegistry: ProviderRegistry,
val defaultOrchestrationConfig: OrchestrationConfig,
) )
@@ -62,7 +62,7 @@ class ApprovalCoordinator(
scheduleTimeout(event.requestId, event.sessionId, event.stageId, event.tier) scheduleTimeout(event.requestId, event.sessionId, event.stageId, event.tier)
} }
suspend fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? { fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
if (resolved.putIfAbsent(msg.requestId, true) != null) { if (resolved.putIfAbsent(msg.requestId, true) != null) {
return ServerMessage.ProtocolError("Approval request ${msg.requestId.value} already resolved") return ServerMessage.ProtocolError("Approval request ${msg.requestId.value} already resolved")
} }
@@ -2,7 +2,6 @@ package com.correx.apps.server.protocol
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
class ProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) class ProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
@@ -10,7 +10,6 @@ import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
@@ -3,7 +3,6 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.ProviderHealthDto import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.core.inference.ProviderHealth import com.correx.core.inference.ProviderHealth
import io.ktor.server.application.call
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
@@ -8,11 +8,8 @@ import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.application
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.respond import io.ktor.server.response.respond
@@ -67,9 +64,7 @@ private fun Route.startSessionRoute(module: ServerModule) {
val graph = module.workflowRegistry.find(body.workflowId) val graph = module.workflowRegistry.find(body.workflowId)
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}") ?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
val sessionId: SessionId = TypeId(UUID.randomUUID().toString()) val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
val config = OrchestrationConfig( val config = module.defaultOrchestrationConfig
defaultSystemPromptPath = "~/.config/correx/prompts/default_system.md",
)
call.application.launch { call.application.launch {
runCatching { module.orchestrator.run(sessionId, graph, config) } runCatching { module.orchestrator.run(sessionId, graph, config) }
.onFailure { ex -> .onFailure { ex ->
@@ -2,7 +2,6 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule import com.correx.apps.server.ServerModule
import com.correx.apps.server.registry.WorkflowSummary import com.correx.apps.server.registry.WorkflowSummary
import io.ktor.server.application.call
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
@@ -12,7 +12,6 @@ import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.inference.ProviderHealth import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.server.websocket.DefaultWebSocketServerSession import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
@@ -104,7 +103,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId) is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported")) is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
is ClientMessage.ApprovalResponse -> is ClientMessage.ApprovalResponse ->
session.send(encodeError("ApprovalResponse not supported on global stream")) session.send(encodeError("ApprovalResponse must be sent to /sessions/{id}/stream"))
} }
} }
@@ -127,10 +126,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
val sessionId: SessionId = TypeId(UUID.randomUUID().toString()) val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
log.info("starting session={} workflow={}", sessionId.value, msg.workflowId) log.info("starting session={} workflow={}", sessionId.value, msg.workflowId)
session.launch { session.launch {
val config = OrchestrationConfig( runCatching { module.orchestrator.run(sessionId, graph, module.defaultOrchestrationConfig) }
defaultSystemPromptPath = "~/.config/correx/prompts/default_system.md",
)
runCatching { module.orchestrator.run(sessionId, graph, config) }
.onFailure { ex -> .onFailure { ex ->
log.error("run failed for session={}: {}", sessionId.value, ex.message, ex) log.error("run failed for session={}: {}", sessionId.value, ex.message, ex)
module.eventStore.append( module.eventStore.append(
@@ -60,7 +60,7 @@ fun main(args: Array<String>) {
state = next state = next
effects.forEach { effect -> effects.forEach { effect ->
effectScope.launch { effectScope.launch {
EffectDispatcher(ws) { runner.quit() }.dispatch(effect) EffectDispatcher(ws, effectScope) { runner.quit() }.dispatch(effect)
} }
} }
} }
@@ -17,7 +17,7 @@ fun inputBarWidget(input: InputState): Paragraph? {
InputMode.WorkflowId -> "new session > " to Style.create().cyan() InputMode.WorkflowId -> "new session > " to Style.create().cyan()
InputMode.Filter -> "filter > " to Style.create().yellow() InputMode.Filter -> "filter > " to Style.create().yellow()
InputMode.SteeringNote -> "steer > " to Style.create().magenta() InputMode.SteeringNote -> "steer > " to Style.create().magenta()
InputMode.None -> "" to Style.create() else -> "" to Style.create()
} }
val line = Line.from( val line = Line.from(
Span.styled(promptText, promptStyle), Span.styled(promptText, promptStyle),
@@ -1,8 +1,11 @@
package com.correx.apps.tui.reducer package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ClientMessage import com.correx.apps.server.protocol.ClientMessage
import com.correx.core.events.types.SessionId
sealed interface Effect { sealed interface Effect {
data class SendWs(val message: ClientMessage) : Effect data class SendWs(val message: ClientMessage) : Effect
data class ConnectSession(val sessionId: SessionId) : Effect
data object DisconnectSession: Effect
data object Quit : Effect data object Quit : Effect
} }
@@ -1,14 +1,18 @@
package com.correx.apps.tui.reducer package com.correx.apps.tui.reducer
import com.correx.apps.tui.ws.TuiWsClient import com.correx.apps.tui.ws.TuiWsClient
import kotlinx.coroutines.CoroutineScope
class EffectDispatcher( class EffectDispatcher(
private val wsClient: TuiWsClient, private val wsClient: TuiWsClient,
private val effectScope: CoroutineScope,
private val onQuit: () -> Unit, private val onQuit: () -> Unit,
) { ) {
suspend fun dispatch(effect: Effect) { suspend fun dispatch(effect: Effect) {
when (effect) { when (effect) {
is Effect.SendWs -> wsClient.send(effect.message) is Effect.SendWs -> wsClient.send(effect.message)
is Effect.ConnectSession -> wsClient.connectSession(effect.sessionId, effectScope)
Effect.DisconnectSession -> wsClient.disconnectSession()
Effect.Quit -> onQuit() Effect.Quit -> onQuit()
} }
} }
@@ -7,7 +7,6 @@ import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.InputState import com.correx.apps.tui.state.InputState
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
object InputReducer { object InputReducer {
@Suppress("CyclomaticComplexMethod") @Suppress("CyclomaticComplexMethod")
@@ -24,11 +24,13 @@ object SessionsReducer {
} else { } else {
sessions to emptyList() sessions to emptyList()
} }
is Action.CancelInput -> if (inputMode == InputMode.Filter) { is Action.CancelInput -> if (inputMode == InputMode.Filter) {
sessions.copy(filter = "") to emptyList() sessions.copy(filter = "") to emptyList()
} else { } else {
sessions to emptyList() sessions to emptyList()
} }
is Action.CancelSelectedSession -> { is Action.CancelSelectedSession -> {
val id = sessions.selectedId val id = sessions.selectedId
if (id != null) { if (id != null) {
@@ -37,7 +39,8 @@ object SessionsReducer {
sessions to emptyList() sessions to emptyList()
} }
} }
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock) to emptyList()
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
else -> sessions to emptyList() else -> sessions to emptyList()
} }
@@ -62,7 +65,7 @@ object SessionsReducer {
sessions: SessionsState, sessions: SessionsState,
msg: ServerMessage, msg: ServerMessage,
clock: () -> Long, clock: () -> Long,
): SessionsState = when (msg) { ): Pair<SessionsState, List<Effect>> = when (msg) {
is ServerMessage.SessionStarted -> { is ServerMessage.SessionStarted -> {
val summary = SessionSummary( val summary = SessionSummary(
id = msg.sessionId.value, id = msg.sessionId.value,
@@ -73,8 +76,10 @@ object SessionsReducer {
lastOutput = null, lastOutput = null,
) )
val selected = sessions.selectedId ?: msg.sessionId.value val selected = sessions.selectedId ?: msg.sessionId.value
sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to
listOf(Effect.ConnectSession(msg.sessionId))
} }
is ServerMessage.SessionPaused -> { is ServerMessage.SessionPaused -> {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) { val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval" "PAUSED awaiting approval"
@@ -85,10 +90,20 @@ object SessionsReducer {
sessions = sessions.sessions.map { s -> sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
}, },
) ) to emptyList()
} }
is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock)
is ServerMessage.SessionFailed -> touchSession(sessions, msg.sessionId.value, "FAILED", clock) is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
Effect.DisconnectSession,
)
is ServerMessage.SessionFailed -> touchSession(
sessions,
msg.sessionId.value,
"FAILED",
clock,
) to listOf(Effect.DisconnectSession)
is ServerMessage.StageStarted -> sessions.copy( is ServerMessage.StageStarted -> sessions.copy(
sessions = sessions.sessions.map { s -> sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) { if (s.id == msg.sessionId.value) {
@@ -97,17 +112,20 @@ object SessionsReducer {
s s
} }
}, },
) ) to emptyList()
is ServerMessage.StageCompleted -> sessions.copy( is ServerMessage.StageCompleted -> sessions.copy(
sessions = sessions.sessions.map { s -> sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
}, },
) ) to emptyList()
is ServerMessage.StageFailed -> sessions.copy( is ServerMessage.StageFailed -> sessions.copy(
sessions = sessions.sessions.map { s -> sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
}, },
) ) to emptyList()
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock) is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
is ServerMessage.ToolCompleted -> sessions.copy( is ServerMessage.ToolCompleted -> sessions.copy(
sessions = sessions.sessions.map { s -> sessions = sessions.sessions.map { s ->
@@ -117,15 +135,16 @@ object SessionsReducer {
s s
} }
}, },
) ) to emptyList()
else -> sessions
else -> sessions to emptyList()
} }
private fun applyInferenceCompleted( private fun applyInferenceCompleted(
sessions: SessionsState, sessions: SessionsState,
msg: ServerMessage.InferenceCompleted, msg: ServerMessage.InferenceCompleted,
clock: () -> Long, clock: () -> Long,
): SessionsState = sessions.copy( ): Pair<SessionsState, List<Effect>> = sessions.copy(
sessions = sessions.sessions.map { s -> sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) { if (s.id == msg.sessionId.value) {
s.copy( s.copy(
@@ -137,7 +156,7 @@ object SessionsReducer {
s s
} }
}, },
) ) to emptyList()
private fun touchSession( private fun touchSession(
sessions: SessionsState, sessions: SessionsState,
@@ -2,6 +2,7 @@ package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ClientMessage import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.events.types.SessionId
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.LogLevel
@@ -13,10 +14,13 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText import io.ktor.websocket.readText
import io.ktor.websocket.send import io.ktor.websocket.send
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -39,6 +43,8 @@ class TuiWsClient(
} }
} }
private var sessionStreamJob: Job? = null
private var sessionWsSession: WebSocketSession? = null
private var session: WebSocketSession? = null private var session: WebSocketSession? = null
private val _messages = MutableSharedFlow<ServerMessage>(extraBufferCapacity = 64) private val _messages = MutableSharedFlow<ServerMessage>(extraBufferCapacity = 64)
@@ -49,10 +55,16 @@ class TuiWsClient(
suspend fun send(message: ClientMessage) { suspend fun send(message: ClientMessage) {
runCatching { runCatching {
session?.send(json.encodeToString(message)) // approval responses go to session stream, everything else to global
val target = when (message) {
is ClientMessage.ApprovalResponse -> sessionWsSession ?: session
else -> session
}
target?.send(json.encodeToString(message))
} }
} }
suspend fun connect() { suspend fun connect() {
var delayMs = INITIAL_RETRY_DELAY_MS var delayMs = INITIAL_RETRY_DELAY_MS
var attempt = 0 var attempt = 0
@@ -84,6 +96,39 @@ class TuiWsClient(
} }
} }
fun connectSession(sessionId: SessionId, scope: CoroutineScope) {
sessionStreamJob?.cancel()
sessionStreamJob = scope.launch {
runCatching {
client.webSocket(
method = HttpMethod.Get,
host = host,
port = port,
path = "/sessions/${sessionId.value}/stream",
) {
sessionWsSession = this
for (frame in incoming) {
if (frame is Frame.Text) {
runCatching {
json.decodeFromString<ServerMessage>(frame.readText())
}.onSuccess { msg ->
_messages.tryEmit(msg)
}
}
}
}
}.also {
sessionWsSession = null
}
}
}
fun disconnectSession() {
sessionStreamJob?.cancel()
sessionStreamJob = null
sessionWsSession = null
}
fun close() { fun close() {
client.close() client.close()
} }
+6 -5
View File
@@ -8,6 +8,7 @@ plugins {
id 'org.jetbrains.kotlin.plugin.compose' version '2.2.10' apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.2.10' apply false
id "io.gitlab.arturbosch.detekt" version "1.23.7" id "io.gitlab.arturbosch.detekt" version "1.23.7"
id "org.jetbrains.kotlinx.kover" version "0.8.3" id "org.jetbrains.kotlinx.kover" version "0.8.3"
id "org.sonarqube" version "5.1.0.4882"
} }
ext { ext {
@@ -49,11 +50,11 @@ subprojects {
kover { kover {
reports { reports {
// verify { verify {
// rule { rule {
// minBound(80) minBound(70)
// } }
// } }
filters { filters {
excludes { excludes {
classes("*Generated*") classes("*Generated*")
@@ -78,6 +78,7 @@ object ConfigLoader {
val tuiSection = sections["tui"] ?: emptyMap() val tuiSection = sections["tui"] ?: emptyMap()
val cliSection = sections["cli"] ?: emptyMap() val cliSection = sections["cli"] ?: emptyMap()
val approvalSection = sections["approval"] ?: emptyMap() val approvalSection = sections["approval"] ?: emptyMap()
val toolsSection = sections["tools"] ?: emptyMap()
val server = ServerConfig( val server = ServerConfig(
host = serverSection["host"] ?: "localhost", host = serverSection["host"] ?: "localhost",
@@ -97,11 +98,22 @@ object ConfigLoader {
timeoutMs = approvalSection["timeout_ms"]?.toLongOrNull() ?: 300_000L, timeoutMs = approvalSection["timeout_ms"]?.toLongOrNull() ?: 300_000L,
) )
val tools = ToolsConfig(
sandboxRoot = toolsSection["sandbox_root"] ?: "",
workingDir = toolsSection["working_dir"] ?: "",
shellAllowedExecutables = toolsSection["shell_allowed_executables"]
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList(),
defaultSystemPromptPath = toolsSection["default_system_prompt_path"]
?: "~/.config/correx/prompts/default_system.md",
)
return CorrexConfig( return CorrexConfig(
server = server, server = server,
tui = tui, tui = tui,
cli = cli, cli = cli,
approval = approval, approval = approval,
tools = tools,
) )
} }
} }
@@ -8,6 +8,7 @@ data class CorrexConfig(
val tui: TuiConfig = TuiConfig(), val tui: TuiConfig = TuiConfig(),
val cli: CliConfig = CliConfig(), val cli: CliConfig = CliConfig(),
val approval: ApprovalConfig = ApprovalConfig(), val approval: ApprovalConfig = ApprovalConfig(),
val tools: ToolsConfig = ToolsConfig(),
) )
@Serializable @Serializable
@@ -31,3 +32,11 @@ data class CliConfig(
data class ApprovalConfig( data class ApprovalConfig(
val timeoutMs: Long = 300_000L, val timeoutMs: Long = 300_000L,
) )
@Serializable
data class ToolsConfig(
val sandboxRoot: String = "",
val workingDir: String = "",
val shellAllowedExecutables: List<String> = emptyList(),
val defaultSystemPromptPath: String = "~/.config/correx/prompts/default_system.md",
)
@@ -1,17 +0,0 @@
package com.correx.core.events.types
object EventTypes {
const val SESSION_STARTED = "session.started"
const val SESSION_PAUSED = "session.paused"
const val SESSION_COMPLETED = "session.completed"
const val TOOL_INVOKED = "tool.invoked"
const val TOOL_COMPLETED = "tool.completed"
const val APPROVAL_REQUESTED = "approval.requested"
const val APPROVAL_DECISION_RESOLVED = "approval.decision_resolved"
const val APPROVAL_GRANT_CREATED = "approval.grant_created"
const val APPROVAL_GRANT_EXPIRED = "approval.grant_expired"
const val ARTIFACT_CREATED = "artifact.created"
}
@@ -96,7 +96,7 @@ class ReplayOrchestrator(
currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision), currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision),
), ),
) )
is ReplayStepResult.Terminal -> return result.result is ReplayStepResult.Terminal -> result.result
} }
} }
+25 -2
View File
@@ -1,3 +1,26 @@
build:
maxIssues: 999999
style: style:
NewLineAtEndOfFile: active: true
active: false
complexity:
active: true
empty-blocks:
active: true
exceptions:
active: true
naming:
active: true
performance:
active: true
potential-bugs:
active: true
comments:
active: true
@@ -20,7 +20,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -24,9 +24,19 @@ import java.nio.file.StandardOpenOption
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
class FileEditTool( class FileEditTool(
allowedPaths: Set<Path> = emptySet(), allowedPaths: Set<Path> = emptySet(),
private val workingDir: Path? = null,
) : Tool, FileAffectingTool, ToolExecutor { ) : Tool, FileAffectingTool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
private fun resolvePath(pathString: String): Path {
val raw = Paths.get(pathString)
return when {
raw.isAbsolute -> raw.normalize()
workingDir != null -> workingDir.resolve(raw).normalize()
else -> raw.toAbsolutePath().normalize()
}
}
override val name: String = "file_edit" override val name: String = "file_edit"
override val description: String = "Edit content in a file at the specified path" override val description: String = "Edit content in a file at the specified path"
override val parametersSchema: JsonObject = buildJsonObject {} override val parametersSchema: JsonObject = buildJsonObject {}
@@ -35,7 +45,7 @@ class FileEditTool(
override fun affectedPaths(request: ToolRequest): Set<Path> { override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet() val pathString = request.parameters["path"] as? String ?: return emptySet()
return setOf(Paths.get(pathString).normalize().toAbsolutePath()) return setOf(resolvePath(pathString))
} }
override fun validateRequest(request: ToolRequest): ValidationResult { override fun validateRequest(request: ToolRequest): ValidationResult {
@@ -50,7 +60,7 @@ class FileEditTool(
ValidationResult.Invalid("Missing 'path' parameter. Expected String.") ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
else -> runCatching { else -> runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath() val path = resolvePath(pathString)
checkPathAllowed(path, pathString, operation, request) checkPathAllowed(path, pathString, operation, request)
}.getOrElse { e -> }.getOrElse { e ->
mapExceptionToValidationResult(e) mapExceptionToValidationResult(e)
@@ -116,7 +126,7 @@ class FileEditTool(
val operation = request.parameters["operation"] as String val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath() val path = resolvePath(pathString)
runCatching { runCatching {
performOperation(operation, path, pathString, request) performOperation(operation, path, pathString, request)
@@ -18,10 +18,10 @@ import java.nio.file.InvalidPathException
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
@Suppress("UnusedParameter")
class FileReadTool( class FileReadTool(
allowedPaths: Set<Path> = emptySet(), allowedPaths: Set<Path> = emptySet(),
) : Tool, ToolExecutor { ) : Tool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_read" override val name: String = "file_read"
override val description: String = "Read content from a file at the specified path" override val description: String = "Read content from a file at the specified path"
@@ -32,12 +32,8 @@ class FileReadTool(
override fun validateRequest(request: ToolRequest): ValidationResult = override fun validateRequest(request: ToolRequest): ValidationResult =
(request.parameters["path"] as? String)?.let { pathString -> (request.parameters["path"] as? String)?.let { pathString ->
runCatching { runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath() Paths.get(pathString)
val isAllowed = normalizedAllowedPaths.isEmpty() || path in normalizedAllowedPaths ValidationResult.Valid
|| normalizedAllowedPaths.any { path.startsWith(it) }
ValidationResult.Valid.takeIf { isAllowed }
?: ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
}.getOrElse { }.getOrElse {
when (it) { when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}") is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
@@ -1,11 +1,11 @@
package com.correx.infrastructure.tools.filesystem package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier import com.correx.core.approvals.Tier
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifacts.MaterializationResult
import com.correx.core.artifacts.MaterializingArtifactWriter import com.correx.core.artifacts.MaterializingArtifactWriter
import com.correx.core.artifacts.kind.FileWrittenArtifact import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.FileWrittenPayload import com.correx.core.artifacts.kind.FileWrittenPayload
import com.correx.core.artifacts.MaterializationResult import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool import com.correx.core.tools.contract.FileAffectingTool
@@ -36,15 +36,17 @@ class FileWriteTool(
private val artifactStore: ArtifactStore? = null, private val artifactStore: ArtifactStore? = null,
private val materializingWriter: MaterializingArtifactWriter? = null, private val materializingWriter: MaterializingArtifactWriter? = null,
private val sandboxRoot: Path? = null, private val sandboxRoot: Path? = null,
private val workingDir: Path? = null,
) : Tool, FileAffectingTool, ToolExecutor { ) : Tool, FileAffectingTool, ToolExecutor {
private companion object { private companion object {
val log = LoggerFactory.getLogger(FileWriteTool::class.java) val log = LoggerFactory.getLogger(FileWriteTool::class.java)
} }
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_write" override val name: String = "file_write"
override val description: String = "Write content to a file at the specified path" override val description: String = "Write content to a file at the specified path or delete the file entirely"
override val parametersSchema: JsonObject = buildJsonObject { override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object") put("type", "object")
putJsonObject("properties") { putJsonObject("properties") {
@@ -56,15 +58,31 @@ class FileWriteTool(
put("type", "string") put("type", "string")
put("description", "File content") put("description", "File content")
} }
putJsonObject("operation") {
put("type", "string")
put("description", "Either 'write' or 'delete'")
}
} }
put("required", buildJsonArray { add(JsonPrimitive("path")); add(JsonPrimitive("content")) }) put(
"required",
buildJsonArray { add(JsonPrimitive("path")); add(JsonPrimitive("content")); add(JsonPrimitive("operation")) },
)
} }
override val tier: Tier = Tier.T2 override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE) override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override fun affectedPaths(request: ToolRequest): Set<Path> { override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet() val pathString = request.parameters["path"] as? String ?: return emptySet()
return setOf(Paths.get(pathString).normalize().toAbsolutePath()) return setOf(resolvePath(pathString))
}
private fun resolvePath(pathString: String): Path {
val raw = Paths.get(pathString)
return when {
raw.isAbsolute -> raw.normalize()
workingDir != null -> workingDir.resolve(raw).normalize()
else -> raw.toAbsolutePath().normalize()
}
} }
override fun validateRequest(request: ToolRequest): ValidationResult { override fun validateRequest(request: ToolRequest): ValidationResult {
@@ -79,7 +97,7 @@ class FileWriteTool(
ValidationResult.Invalid("Missing 'path' parameter. Expected String.") ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
else -> runCatching { else -> runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath() val path = resolvePath(pathString)
checkPathAllowed(path, pathString, operation, request) checkPathAllowed(path, pathString, operation, request)
}.getOrElse { e -> }.getOrElse { e ->
mapExceptionToValidationResult(e) mapExceptionToValidationResult(e)
@@ -133,7 +151,7 @@ class FileWriteTool(
val operation = request.parameters["operation"] as String val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath() val path = resolvePath(pathString)
runCatching { runCatching {
performOperation(operation, path, pathString, request) performOperation(operation, path, pathString, request)
@@ -195,6 +213,7 @@ class FileWriteTool(
).toByteArray(Charsets.UTF_8) ).toByteArray(Charsets.UTF_8)
store.put(canonicalBytes) store.put(canonicalBytes)
} }
is MaterializationResult.Failure -> is MaterializationResult.Failure ->
log.warn("[FileWriteTool] artifact materialization failed for {}: {}", pathString, result.reason) log.warn("[FileWriteTool] artifact materialization failed for {}: {}", pathString, result.reason)
} }
@@ -1,6 +1,5 @@
package com.correx.infrastructure.tools package com.correx.infrastructure.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.EventDispatcher import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent
@@ -23,9 +23,14 @@ data class FileWriteConfig(
val artifactStore: ArtifactStore? = null, val artifactStore: ArtifactStore? = null,
val materializingWriter: MaterializingArtifactWriter? = null, val materializingWriter: MaterializingArtifactWriter? = null,
val sandboxRoot: Path? = null, val sandboxRoot: Path? = null,
val workingDir: Path? = null,
) )
data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet()) data class FileEditConfig(
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet()) val enabled: Boolean = false,
val allowedPaths: Set<Path> = emptySet(),
val workingDir: Path? = null,
)
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet(), val workingDir: Path? = null)
/** /**
* Extension function to convert [ToolConfig] into a list of [Tool] implementations. * Extension function to convert [ToolConfig] into a list of [Tool] implementations.
@@ -33,7 +38,8 @@ data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set
fun ToolConfig.buildTools(): List<Tool> = buildList { fun ToolConfig.buildTools(): List<Tool> = buildList {
if (shell.enabled) { if (shell.enabled) {
add(ShellTool( add(ShellTool(
allowedExecutables = shell.allowedExecutables allowedExecutables = shell.allowedExecutables,
workingDir = shell.workingDir,
)) ))
} }
if (fileRead.enabled) { if (fileRead.enabled) {
@@ -47,11 +53,13 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
artifactStore = fileWrite.artifactStore, artifactStore = fileWrite.artifactStore,
materializingWriter = fileWrite.materializingWriter, materializingWriter = fileWrite.materializingWriter,
sandboxRoot = fileWrite.sandboxRoot, sandboxRoot = fileWrite.sandboxRoot,
workingDir = fileWrite.workingDir,
)) ))
} }
if (fileEdit.enabled) { if (fileEdit.enabled) {
add(FileEditTool( add(FileEditTool(
allowedPaths = fileEdit.allowedPaths allowedPaths = fileEdit.allowedPaths,
workingDir = fileEdit.workingDir,
)) ))
} }
} }
@@ -14,10 +14,12 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import java.nio.file.Path
class ShellTool( class ShellTool(
private val allowedExecutables: Set<String> = emptySet(), private val allowedExecutables: Set<String> = emptySet(),
private val timeoutMs: Long = 30_000L, private val timeoutMs: Long = 30_000L,
private val workingDir: Path? = null,
) : Tool, ToolExecutor { ) : Tool, ToolExecutor {
override val name: String = "shell" override val name: String = "shell"
override val description: String = "Execute a shell command" override val description: String = "Execute a shell command"
@@ -31,7 +33,7 @@ class ShellTool(
return when { return when {
argv.isNullOrEmpty() -> argv.isNullOrEmpty() ->
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.") ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
argv[0] !in allowedExecutables -> allowedExecutables.isNotEmpty() && argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.") ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
else -> ValidationResult.Valid else -> ValidationResult.Valid
} }
@@ -42,7 +44,7 @@ class ShellTool(
takeIf { this is ValidationResult.Valid }?.let { takeIf { this is ValidationResult.Valid }?.let {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>() val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
val process = ProcessBuilder(argv).apply { val process = ProcessBuilder(argv).apply {
environment().clear() workingDir?.let { directory(it.toFile()) }
}.start() }.start()
runCatching { runCatching {
runCmd(request, process) runCmd(request, process)