epic-12: after epic audit and init commit

This commit is contained in:
2026-05-16 11:42:00 +04:00
commit c77277af0b
461 changed files with 28958 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation project(":core:tools")
implementation project(":core:events")
implementation project(":core:approvals")
implementation project(":core:sessions")
implementation project(":infrastructure:tools:filesystem")
}
@@ -0,0 +1,11 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation project(':core:tools')
implementation project(':core:events')
implementation project(':core:approvals')
}
@@ -0,0 +1,231 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
@Suppress("TooManyFunctions")
class FileEditTool(
allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_edit"
override val tier: Tier = Tier.T3
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet()
return setOf(Paths.get(pathString).normalize().toAbsolutePath())
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val operation = request.parameters["operation"] as? String
val pathString = request.parameters["path"] as? String
return when {
operation == null ->
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'append', 'replace', or 'patch'.")
pathString == null ->
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
else -> runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
checkPathAllowed(path, pathString, operation, request)
}.getOrElse { e ->
mapExceptionToValidationResult(e)
}
}
}
private fun checkPathAllowed(
path: Path,
pathString: String,
operation: String,
request: ToolRequest,
): ValidationResult {
return when {
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
!isPathAllowed(path) ->
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
!Files.exists(path) ->
ValidationResult.Invalid("File not found: $pathString")
operation == "append" && !request.parameters.containsKey("content") ->
ValidationResult.Invalid("Missing 'content' parameter for 'append' operation.")
operation == "replace" && (
!request.parameters.containsKey("target") ||
!request.parameters.containsKey("replacement")
) ->
ValidationResult.Invalid("Missing 'target' or 'replacement' parameter for 'replace' operation.")
operation == "patch" && !request.parameters.containsKey("patch") ->
ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.")
operation !in listOf("append", "replace", "patch") ->
ValidationResult.Invalid("Unknown operation: $operation")
else -> ValidationResult.Valid
}
}
private fun isPathAllowed(path: Path): Boolean =
normalizedAllowedPaths.any { allowedPath ->
path.startsWith(allowedPath)
}
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
when (e) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid) {
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = validation.reason,
recoverable = false,
)
}
val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
runCatching {
performOperation(operation, path, pathString, request)
}.getOrElse { e ->
handleExecutionException(e, request.invocationId, pathString)
}
}
private fun performOperation(
operation: String,
path: Path,
pathString: String,
request: ToolRequest,
): ToolResult = when (operation) {
"append" -> append(path, request)
"replace" -> replace(path, pathString, request)
"patch" -> patch(path, pathString, request)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Unknown operation: $operation",
recoverable = false,
)
}
private fun append(path: Path, request: ToolRequest): ToolResult {
val content = request.parameters["content"] as String
Files.writeString(path, content, StandardOpenOption.APPEND)
return ToolResult.Success(
invocationId = request.invocationId,
output = "Content appended to ${path.toAbsolutePath()}",
)
}
private fun replace(path: Path, pathString: String, request: ToolRequest): ToolResult {
val target = request.parameters["target"] as String
val replacement = request.parameters["replacement"] as String
val currentContent = Files.readString(path)
val occurrences = currentContent.split(target).size - 1
return if (occurrences == 1) {
val newContent = currentContent.replace(target, replacement)
Files.writeString(path, newContent)
ToolResult.Success(
invocationId = request.invocationId,
output = "Target replaced in $pathString",
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = if (occurrences == 0) {
"Target '$target' not found in $pathString"
} else {
"Target '$target' found $occurrences times, expected exactly once"
},
recoverable = false,
)
}
}
private fun patch(path: Path, pathString: String, request: ToolRequest): ToolResult {
val patchContent = request.parameters["patch"] as String
val parentDir = path.parent ?: Paths.get(".")
val fileName = path.fileName.toString()
val process = ProcessBuilder("patch", "-p1", fileName)
.directory(parentDir.toFile())
.redirectErrorStream(true)
.start()
return try {
process.outputStream.use { it.write(patchContent.toByteArray()) }
val output = process.inputStream.bufferedReader().use { it.readText() }
val exitCode = process.waitFor()
if (exitCode == 0) {
ToolResult.Success(
invocationId = request.invocationId,
output = "Patch applied successfully to $pathString: $output",
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Patch failed with exit code $exitCode: $output",
recoverable = false,
)
}
} finally {
process.destroy()
}
}
private fun handleExecutionException(
e: Throwable,
invocationId: ToolInvocationId,
pathString: String,
): ToolResult = when (e) {
is CancellationException -> throw e
is IOException -> ToolResult.Failure(
invocationId = invocationId,
reason = "IO error: ${e.message}",
recoverable = false,
)
is SecurityException -> ToolResult.Failure(
invocationId = invocationId,
reason = "Access denied: $pathString, ${e.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = invocationId,
reason = e.message ?: "Unknown error occurred",
recoverable = false,
)
}
}
@@ -0,0 +1,97 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
class FileReadTool(
allowedPaths: Set<Path> = emptySet(),
) : Tool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_read"
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override fun validateRequest(request: ToolRequest): ValidationResult =
(request.parameters["path"] as? String)?.let { pathString ->
runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
val isAllowed = normalizedAllowedPaths.isEmpty() || path in normalizedAllowedPaths
|| normalizedAllowedPaths.any { path.startsWith(it) }
ValidationResult.Valid.takeIf { isAllowed }
?: ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
}.getOrElse {
when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
else -> ValidationResult.Invalid(it.message ?: "Unknown error occured")
}
}
} ?: ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
takeIf { it is ValidationResult.Valid }?.let {
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
if (Files.exists(path)) {
readStringCatching(request, path, pathString)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
}
} ?: ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
recoverable = false,
)
}
}
private fun readStringCatching(request: ToolRequest, path: Path, pathString: String): ToolResult =
runCatching {
ToolResult.Success(
invocationId = request.invocationId,
output = Files.readString(path),
)
}.getOrElse {
when (it) {
is CancellationException -> throw it
is SecurityException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Access denied: $pathString, ${it.message}",
recoverable = false,
)
is IOException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "IO error: ${it.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred while reading file",
recoverable = false,
)
}
}
}
@@ -0,0 +1,172 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
class FileWriteTool(
allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_write"
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet()
return setOf(Paths.get(pathString).normalize().toAbsolutePath())
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val operation = request.parameters["operation"] as? String
val pathString = request.parameters["path"] as? String
return when {
operation == null ->
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'write' or 'delete'.")
pathString == null ->
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
else -> runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
checkPathAllowed(path, pathString, operation, request)
}.getOrElse { e ->
mapExceptionToValidationResult(e)
}
}
}
private fun checkPathAllowed(
path: Path,
pathString: String,
operation: String,
request: ToolRequest,
): ValidationResult {
return when {
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
!isPathAllowed(path) ->
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
operation == "write" && !request.parameters.containsKey("content") ->
ValidationResult.Invalid("Missing 'content' parameter for 'write' operation.")
operation != "write" && operation != "delete" ->
ValidationResult.Invalid("Unknown operation: $operation")
else -> ValidationResult.Valid
}
}
private fun isPathAllowed(path: Path): Boolean =
normalizedAllowedPaths.any { allowedPath ->
path.startsWith(allowedPath)
}
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
when (e) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid) {
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = validation.reason,
recoverable = false,
)
}
val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
runCatching {
performOperation(operation, path, pathString, request)
}.getOrElse { e ->
handleExecutionException(e, request.invocationId, pathString)
}
}
private fun performOperation(
operation: String,
path: Path,
pathString: String,
request: ToolRequest,
): ToolResult = when (operation) {
"write" -> {
val content = request.parameters["content"] as String
Files.writeString(path, content)
ToolResult.Success(
invocationId = request.invocationId,
output = "File written successfully to $pathString",
)
}
"delete" -> {
if (Files.exists(path)) {
Files.deleteIfExists(path)
ToolResult.Success(
invocationId = request.invocationId,
output = "File deleted successfully: $pathString",
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
}
}
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Unknown operation: $operation",
recoverable = false,
)
}
private fun handleExecutionException(
e: Throwable,
invocationId: ToolInvocationId,
pathString: String,
): ToolResult = when (e) {
is CancellationException -> throw e
is IOException -> ToolResult.Failure(
invocationId = invocationId,
reason = "IO error: ${e.message}",
recoverable = false,
)
is SecurityException -> ToolResult.Failure(
invocationId = invocationId,
reason = "Access denied: $pathString, ${e.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = invocationId,
reason = e.message ?: "Unknown error occurred",
recoverable = false,
)
}
}
@@ -0,0 +1,234 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
class FileEditToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(
parameters: Map<String, String>,
toolName: String = "file_edit",
): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolName,
parameters = parameters,
)
}
@Test
fun `execute patch success`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_patch")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "line1\nline2\nline3\n")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
// Patch to replace line2 with line2_modified
val patchContent = """
--- a/test.txt
+++ b/test.txt
@@ -2 +2 @@
-line2
+line2_modified
""".trimIndent() + "\n"
val request = createRequest(
mapOf(
"operation" to "patch",
"path" to filePath.toString(),
"patch" to patchContent,
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success, "Expected Success but got $result")
val content = Files.readString(filePath)
assertEquals("line1\nline2_modified\nline3\n", content)
}
@Test
fun `validateRequest returns Invalid for disallowed path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_test")
val otherDir = Files.createTempDirectory("other_dir")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "append",
"path" to otherDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list"))
}
@Test
fun `validateRequest returns Invalid for non-existent file`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_test")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "append",
"path" to tempDir.resolve("non_existent.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("File not found"))
}
@Test
fun `validateRequest returns Invalid for missing parameters`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_test")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "content")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
// Missing operation
val req1 = createRequest(mapOf("path" to filePath.toString()))
assertTrue(tool.validateRequest(req1) is ValidationResult.Invalid)
// Missing content for append
val req2 = createRequest(mapOf("operation" to "append", "path" to filePath.toString()))
assertTrue(tool.validateRequest(req2) is ValidationResult.Invalid)
// Missing target/replacement for replace
val req3 = createRequest(mapOf("operation" to "replace", "path" to filePath.toString(), "target" to "content"))
assertTrue(tool.validateRequest(req3) is ValidationResult.Invalid)
// Missing patch for patch
val req4 = createRequest(mapOf("operation" to "patch", "path" to filePath.toString()))
assertTrue(tool.validateRequest(req4) is ValidationResult.Invalid)
}
@Test
fun `execute append success`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_append")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "hello")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "append",
"path" to filePath.toString(),
"content" to " world",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
assertEquals("hello world", Files.readString(filePath))
}
@Test
fun `execute replace success`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "the quick brown fox")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "replace",
"path" to filePath.toString(),
"target" to "brown",
"replacement" to "red",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
assertEquals("the quick red fox", Files.readString(filePath))
}
@Test
fun `execute replace failure zero matches`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace_fail")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "the quick brown fox")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "replace",
"path" to filePath.toString(),
"target" to "green",
"replacement" to "red",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("not found"))
}
@Test
fun `execute replace failure multiple matches`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace_fail_multi")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "fox fox fox")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "replace",
"path" to filePath.toString(),
"target" to "fox",
"replacement" to "dog",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("found 3 times"))
}
@Test
fun `execute patch failure`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_patch_fail")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "original")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val patchContent = """
--- test.txt
+++ test.txt
@@ -1 +1 @@
-nonexistent
+something
""".trimIndent()
val request = createRequest(
mapOf(
"operation" to "patch",
"path" to filePath.toString(),
"patch" to patchContent,
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("Patch failed"))
}
}
@@ -0,0 +1,112 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.events.events.ToolRequest
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.util.UUID
class FileReadToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(path: String): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "file_read",
parameters = mapOf("path" to path),
)
}
@Test
fun `validateRequest returns Valid for allowed path`() = runBlocking {
val tool = FileReadTool(allowedPaths = emptySet())
val tempFile = Files.createTempFile("test_allowed", ".txt")
val request = createRequest(tempFile.toString())
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
@Test
fun `validateRequest returns Invalid for disallowed path`() = runBlocking {
val tempFile = Files.createTempFile("test_disallowed", ".txt")
val tool = FileReadTool(allowedPaths = setOf(tempFile.parent.resolve("subdir")))
val request = createRequest(tempFile.toString())
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("not in the allowed list"))
}
@Test
fun `validateRequest returns Invalid for missing path parameter`() = runBlocking {
val tool = FileReadTool()
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "file_read",
parameters = emptyMap(),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing or invalid 'path' parameter. Expected String.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `execute returns Success for valid file`() = runBlocking {
val content = "Hello, world!"
val tempFile = Files.createTempFile("test_content", ".txt")
Files.writeString(tempFile, content)
val tool = FileReadTool(allowedPaths = emptySet())
val request = createRequest(tempFile.toString())
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals(content, success.output)
assertEquals(invocationId, success.invocationId)
}
@Test
fun `execute returns Failure for non-existent file`() = runBlocking {
val tempDir = Files.createTempDirectory("test_dir")
val nonExistentFile = tempDir.resolve("missing.txt")
val tool = FileReadTool(allowedPaths = setOf(nonExistentFile))
val request = createRequest(nonExistentFile.toString())
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("File not found"))
assertFalse(failure.recoverable)
}
@Test
fun `execute returns Failure for disallowed path`() = runBlocking {
val tempFile = Files.createTempFile("test_disallowed_exec", ".txt")
val tool = FileReadTool(allowedPaths = setOf(tempFile.parent.resolve("other")))
val request = createRequest(tempFile.toString())
val result = tool.execute(request)
// It should fail at validation stage
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("is not in the allowed list"))
}
}
@@ -0,0 +1,238 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.events.events.ToolRequest
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
class FileWriteToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(
parameters: Map<String, String>,
toolName: String = "file_write",
): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolName,
parameters = parameters,
)
}
@Test
fun `validateRequest returns Valid for allowed path and valid operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"path" to tempDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
@Test
fun `validateRequest returns Invalid for disallowed path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val otherDir = Files.createTempDirectory("other_dir")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"path" to otherDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list"))
}
@Test
fun `validateRequest returns Invalid for missing operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"path" to tempDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing 'operation' parameter. Expected 'write' or 'delete'.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `validateRequest returns Invalid for missing path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Missing 'path' parameter. Expected String.", (result as ValidationResult.Invalid).reason)
}
@Test
fun `validateRequest returns Invalid for missing content on write`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"path" to tempDir.resolve("file.txt").toString(),
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Missing 'content' parameter for 'write' operation.", (result as ValidationResult.Invalid).reason)
}
@Test
fun `validateRequest returns Invalid for unknown operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "unknown",
"path" to tempDir.resolve("file.txt").toString(),
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Unknown operation: unknown", (result as ValidationResult.Invalid).reason)
}
@Test
fun `execute returns Success for write operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = tempDir.resolve("test.txt")
val content = "Hello, FileWriteTool!"
val request = createRequest(
mapOf(
"operation" to "write",
"path" to filePath.toString(),
"content" to content,
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals("File written successfully to ${filePath}", success.output)
assertEquals(invocationId, success.invocationId)
assertEquals(content, Files.readString(filePath))
}
@Test
fun `execute returns Success for delete operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = tempDir.resolve("to_delete.txt")
Files.writeString(filePath, "content to delete")
val request = createRequest(
mapOf(
"operation" to "delete",
"path" to filePath.toString(),
),
)
println("TempDir: $tempDir")
println("FilePath: $filePath")
println("PathString from request: ${request.parameters["path"]}")
val result = tool.execute(request)
println("Result: $result")
println("Exists after: ${Files.exists(filePath)}")
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals("File deleted successfully: $filePath", success.output)
assertEquals(invocationId, success.invocationId)
assertFalse(Files.exists(filePath))
}
@Test
fun `execute returns Failure for deleting non-existent file`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = tempDir.resolve("non_existent.txt")
val request = createRequest(
mapOf(
"operation" to "delete",
"path" to filePath.toString(),
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertEquals("File not found: $filePath", failure.reason)
assertFalse(failure.recoverable)
}
@Test
fun `execute returns Failure for disallowed path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val otherDir = Files.createTempDirectory("other_dir")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = otherDir.resolve("illegal.txt")
val request = createRequest(
mapOf(
"operation" to "write",
"path" to filePath.toString(),
"content" to "illegal",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("is not in the allowed list"))
}
@Test
fun `validateRequest returns Valid for existing file in allowed directory`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test_existing")
val filePath = tempDir.resolve("existing.txt")
Files.writeString(filePath, "content")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "delete",
"path" to filePath.toString(),
),
)
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
}
@@ -0,0 +1,19 @@
package com.correx.infrastructure.tools
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.registry.ToolRegistry
class DefaultToolRegistry private constructor(
private val tools: Map<String, Tool>,
) : ToolRegistry {
override fun resolve(name: String): Tool? = tools[name]
override fun all(): List<Tool> = tools.values.toList()
companion object {
fun build(vararg tools: Tool): ToolRegistry =
DefaultToolRegistry(tools.associateBy { it.name })
fun build(tools: List<Tool>): ToolRegistry =
DefaultToolRegistry(tools.associateBy { it.name })
}
}
@@ -0,0 +1,26 @@
package com.correx.infrastructure.tools
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
class DispatchingToolExecutor(
private val registry: ToolRegistry
) : ToolExecutor {
override suspend fun execute(request: ToolRequest): ToolResult {
val tool = registry.resolve(request.toolName)
?: return ToolResult.Failure(
invocationId = request.invocationId,
reason = "tool not found: ${request.toolName}",
recoverable = false
)
return (tool as? ToolExecutor)?.execute(request)
?: ToolResult.Failure(
invocationId = request.invocationId,
reason = "tool ${request.toolName} does not support execution",
recoverable = false
)
}
}
@@ -0,0 +1,267 @@
package com.correx.infrastructure.tools
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.ApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolExecutionStartedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.sessions.ApprovalMode
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.*
@SuppressWarnings("TooManyFunctions", "LongParameterList")
class SandboxedToolExecutor(
private val delegate: ToolExecutor,
private val registry: ToolRegistry,
private val approvalEngine: ApprovalEngine,
private val eventStore: EventStore,
private val eventDispatcher: EventDispatcher,
private val workDir: Path = Path.of("/tmp/correx-sandbox"),
) : ToolExecutor {
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val sessionId = request.sessionId
val invocationId = request.invocationId
val toolName = request.toolName
val startTime = System.currentTimeMillis()
// 1. resolve tool
val tool = registry.resolve(toolName)
?: return@withContext ToolResult.Failure(
invocationId = invocationId,
reason = "tool not found: $toolName",
recoverable = false,
)
// 2. approval check for T2-T4
val requiresApproval = when (tool.tier) {
Tier.T0, Tier.T1 -> false
Tier.T2, Tier.T3, Tier.T4 -> true
}
if (requiresApproval) {
val sessionEvents = eventStore.read(sessionId)
val decision = evaluateApproval(tool, sessionId, request.stageId, sessionEvents)
if (!decision.isApproved) {
emitRejected(sessionId, invocationId, toolName, tool.tier, decision.reason ?: "approval denied")
return@withContext ToolResult.Failure(
invocationId = invocationId,
reason = decision.reason ?: "approval denied",
recoverable = false,
)
}
}
// 3. emit started
emitStarted(sessionId, invocationId, toolName)
// 4. create working dir
val workingDir = workDir.resolve(sessionId.value).resolve(invocationId.value)
Files.createDirectories(workingDir)
// 5. compute affected paths once (A3: avoid double-invocation divergence)
val affectedPaths: Set<Path> = if (tool is FileAffectingTool) tool.affectedPaths(request) else emptySet()
// 6. backup affected files
val backupMap: Map<Path, Path> = try {
backupAffectedFiles(affectedPaths, workingDir)
} catch (e: IOException) {
return@withContext ToolResult.Failure(
invocationId = invocationId,
reason = "backup failed: ${e.message}",
recoverable = false,
)
}
// 7. delegate
val durationMs = { System.currentTimeMillis() - startTime }
when (val result = delegate.execute(request)) {
is ToolResult.Success -> {
restoreOrClean(backupMap, success = true)
cleanWorkingDir(workingDir)
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs())
result
}
is ToolResult.Failure -> {
restoreOrClean(backupMap, success = false)
cleanWorkingDir(workingDir)
emitFailed(sessionId, invocationId, toolName, result.reason)
result
}
}
}
// --- approval ---
private fun evaluateApproval(
tool: Tool,
sessionId: SessionId,
stageId: StageId,
sessionEvents: List<StoredEvent>,
) = approvalEngine.evaluate(
request = DomainApprovalRequest(
id = ApprovalRequestId(UUID.randomUUID().toString()),
tier = tool.tier,
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
riskSummaryId = null,
timestamp = Clock.System.now(),
causationId = null,
correlationId = null,
),
context = ApprovalContext(
identity = ApprovalScopeIdentity(sessionId, stageId, null),
mode = ApprovalMode.PROMPT,
),
grants = extractGrants(sessionEvents),
now = Clock.System.now(),
)
private fun extractGrants(events: List<StoredEvent>): List<ApprovalGrant> =
events
.mapNotNull { it.payload as? ApprovalGrantCreatedEvent }
.map { e ->
ApprovalGrant(
id = e.grantId,
scope = e.scope,
permittedTiers = e.permittedTiers,
reason = e.reason,
timestamp = Clock.System.now(),
expiresAt = e.expiresAt,
)
}
// --- backup/restore ---
/**
* Returns a map of originalPath -> backupPath for all affected files.
* Throws IOException if any backup fails — caller handles this.
*/
private fun backupAffectedFiles(
affectedPaths: Collection<Path>,
workingDir: Path,
): Map<Path, Path> = buildMap {
for (original in affectedPaths) {
// A1: skip files that don't yet exist (new-file tools have nothing to back up)
if (!Files.exists(original)) continue
val backupPath = workingDir.resolve("${original.fileName}.bak")
Files.copy(original, backupPath, StandardCopyOption.REPLACE_EXISTING)
put(original, backupPath)
}
}
/**
* On success: delete backups.
* On failure: restore originals from backups.
*/
private fun restoreOrClean(backupMap: Map<Path, Path>, success: Boolean) {
for ((original, backup) in backupMap) {
try {
if (success) {
Files.deleteIfExists(backup)
} else {
Files.move(backup, original, StandardCopyOption.REPLACE_EXISTING)
}
} catch (_: IOException) {
// best effort
}
}
}
private fun cleanWorkingDir(workingDir: Path) {
try {
Files.walk(workingDir)
.sorted(Comparator.reverseOrder())
.forEach { Files.deleteIfExists(it) }
} catch (_: IOException) {
// best effort
}
}
// --- event emission ---
private suspend fun emitRejected(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
tier: Tier,
reason: String,
) = emit(sessionId, ToolExecutionRejectedEvent(invocationId, sessionId, toolName, tier, reason))
private suspend fun emitStarted(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName))
private suspend fun emitCompleted(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
result: ToolResult.Success,
tool: Tool,
affectedPaths: Set<Path>,
durationMs: Long,
) {
val affectedEntities = affectedPaths.map { it.toString() }
emit(
sessionId,
ToolExecutionCompletedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolName,
receipt = ToolReceipt(
invocationId = invocationId,
toolName = toolName,
exitCode = result.exitCode,
outputSummary = result.output,
structuredOutput = result.metadata,
affectedEntities = affectedEntities,
durationMs = durationMs,
tier = tool.tier,
timestamp = Clock.System.now(),
),
),
)
}
private suspend fun emitFailed(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
reason: String,
) = emit(sessionId, ToolExecutionFailedEvent(invocationId, sessionId, toolName, reason))
private suspend fun emit(sessionId: SessionId, payload: EventPayload) {
eventDispatcher.emit(payload, sessionId)
}
}
@@ -0,0 +1,46 @@
package com.correx.infrastructure.tools
import com.correx.core.tools.contract.Tool
import com.correx.infrastructure.tools.filesystem.FileEditTool
import com.correx.infrastructure.tools.filesystem.FileReadTool
import com.correx.infrastructure.tools.filesystem.FileWriteTool
import com.correx.infrastructure.tools.shell.ShellTool
import java.nio.file.Path
data class ToolConfig(
val shell: ShellConfig = ShellConfig(),
val fileRead: FileReadConfig = FileReadConfig(),
val fileWrite: FileWriteConfig = FileWriteConfig(),
val fileEdit: FileEditConfig = FileEditConfig(),
)
data class FileReadConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class FileWriteConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet())
/**
* Extension function to convert [ToolConfig] into a list of [Tool] implementations.
*/
fun ToolConfig.buildTools(): List<Tool> = buildList {
if (shell.enabled) {
add(ShellTool(
allowedExecutables = shell.allowedExecutables
))
}
if (fileRead.enabled) {
add(FileReadTool(
allowedPaths = fileRead.allowedPaths
))
}
if (fileWrite.enabled) {
add(FileWriteTool(
allowedPaths = fileWrite.allowedPaths
))
}
if (fileEdit.enabled) {
add(FileEditTool(
allowedPaths = fileEdit.allowedPaths
))
}
}
@@ -0,0 +1,93 @@
package com.correx.infrastructure.tools.shell
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
class ShellTool(
private val allowedExecutables: Set<String> = emptySet(),
private val timeoutMs: Long = 30_000L,
) : Tool, ToolExecutor {
override val name: String = "shell"
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> =
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
override fun validateRequest(request: ToolRequest): ValidationResult {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
return when {
argv.isNullOrEmpty() ->
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
else -> ValidationResult.Valid
}
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
takeIf { this is ValidationResult.Valid }?.let {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
val process = ProcessBuilder(argv).apply {
environment().clear()
}.start()
runCatching {
runCmd(request, process)
}.getOrElse {
process.destroyForcibly()
if (it is TimeoutCancellationException) {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process timed out after ${timeoutMs}ms",
recoverable = false,
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred during execution",
recoverable = false,
)
}
}
} ?: ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
recoverable = false,
)
}
}
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
val exitCode = process.waitFor()
val stdout = stdoutDeferred.await()
val stderr = stderrDeferred.await()
val metadata = if (stderr.isNotBlank()) mapOf("stderr" to stderr) else emptyMap()
if (exitCode != 0) {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process exited with code $exitCode${if (stderr.isNotBlank()) ": $stderr" else ""}",
recoverable = false,
)
} else {
ToolResult.Success(
invocationId = request.invocationId,
output = stdout,
exitCode = exitCode,
metadata = metadata,
)
}
}
}
@@ -0,0 +1,115 @@
package com.correx.infrastructure.tools.shell
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.UUID
class ShellToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(argv: List<String>): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "shell",
parameters = mapOf("argv" to argv),
)
}
@Test
fun `validateRequest returns Valid for allowed executable`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(listOf("echo", "hello"))
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
@Test
fun `validateRequest returns Invalid for disallowed executable`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(listOf("ls"))
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Executable 'ls' is not in the allowed list.", (result as ValidationResult.Invalid).reason)
}
@Test
fun `validateRequest returns Invalid for empty argv`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(emptyList())
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing or empty 'argv' parameter. Expected List<String>.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `validateRequest returns Invalid for null argv`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "shell",
parameters = emptyMap(),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing or empty 'argv' parameter. Expected List<String>.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `execute returns Success for exit code 0`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(listOf("echo", "hello"))
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertTrue(success.output.startsWith("hello"))
assertEquals(0, success.exitCode)
assertEquals(invocationId, success.invocationId)
}
@Test
fun `execute returns Failure with non-zero exit code for failed process`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("false"))
val request = createRequest(listOf("false"))
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("exited with code 1"))
assertEquals(invocationId, failure.invocationId)
}
@Test
fun `execute returns Failure on timeout`() = runBlocking {
// Using sleep command to simulate timeout
val tool = ShellTool(allowedExecutables = setOf("sleep"), timeoutMs = 100L)
val request = createRequest(listOf("sleep", "1"))
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertEquals("Process timed out after 100ms", failure.reason)
assertFalse(failure.recoverable)
}
}