epic-12: after epic audit and init commit
This commit is contained in:
@@ -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')
|
||||
}
|
||||
+231
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+97
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+234
@@ -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"))
|
||||
}
|
||||
}
|
||||
+112
@@ -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"))
|
||||
}
|
||||
}
|
||||
+238
@@ -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))
|
||||
}
|
||||
}
|
||||
+19
@@ -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 })
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+267
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
+93
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user