epic-12: after epic audit and init commit
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user