epic-12: after epic audit and init commit

This commit is contained in:
2026-05-16 11:42:00 +04:00
commit c77277af0b
461 changed files with 28958 additions and 0 deletions
@@ -0,0 +1,11 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation project(':core:tools')
implementation project(':core:events')
implementation project(':core:approvals')
}
@@ -0,0 +1,231 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
@Suppress("TooManyFunctions")
class FileEditTool(
allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_edit"
override val tier: Tier = Tier.T3
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet()
return setOf(Paths.get(pathString).normalize().toAbsolutePath())
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val operation = request.parameters["operation"] as? String
val pathString = request.parameters["path"] as? String
return when {
operation == null ->
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'append', 'replace', or 'patch'.")
pathString == null ->
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
else -> runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
checkPathAllowed(path, pathString, operation, request)
}.getOrElse { e ->
mapExceptionToValidationResult(e)
}
}
}
private fun checkPathAllowed(
path: Path,
pathString: String,
operation: String,
request: ToolRequest,
): ValidationResult {
return when {
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
!isPathAllowed(path) ->
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
!Files.exists(path) ->
ValidationResult.Invalid("File not found: $pathString")
operation == "append" && !request.parameters.containsKey("content") ->
ValidationResult.Invalid("Missing 'content' parameter for 'append' operation.")
operation == "replace" && (
!request.parameters.containsKey("target") ||
!request.parameters.containsKey("replacement")
) ->
ValidationResult.Invalid("Missing 'target' or 'replacement' parameter for 'replace' operation.")
operation == "patch" && !request.parameters.containsKey("patch") ->
ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.")
operation !in listOf("append", "replace", "patch") ->
ValidationResult.Invalid("Unknown operation: $operation")
else -> ValidationResult.Valid
}
}
private fun isPathAllowed(path: Path): Boolean =
normalizedAllowedPaths.any { allowedPath ->
path.startsWith(allowedPath)
}
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
when (e) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid) {
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = validation.reason,
recoverable = false,
)
}
val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
runCatching {
performOperation(operation, path, pathString, request)
}.getOrElse { e ->
handleExecutionException(e, request.invocationId, pathString)
}
}
private fun performOperation(
operation: String,
path: Path,
pathString: String,
request: ToolRequest,
): ToolResult = when (operation) {
"append" -> append(path, request)
"replace" -> replace(path, pathString, request)
"patch" -> patch(path, pathString, request)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Unknown operation: $operation",
recoverable = false,
)
}
private fun append(path: Path, request: ToolRequest): ToolResult {
val content = request.parameters["content"] as String
Files.writeString(path, content, StandardOpenOption.APPEND)
return ToolResult.Success(
invocationId = request.invocationId,
output = "Content appended to ${path.toAbsolutePath()}",
)
}
private fun replace(path: Path, pathString: String, request: ToolRequest): ToolResult {
val target = request.parameters["target"] as String
val replacement = request.parameters["replacement"] as String
val currentContent = Files.readString(path)
val occurrences = currentContent.split(target).size - 1
return if (occurrences == 1) {
val newContent = currentContent.replace(target, replacement)
Files.writeString(path, newContent)
ToolResult.Success(
invocationId = request.invocationId,
output = "Target replaced in $pathString",
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = if (occurrences == 0) {
"Target '$target' not found in $pathString"
} else {
"Target '$target' found $occurrences times, expected exactly once"
},
recoverable = false,
)
}
}
private fun patch(path: Path, pathString: String, request: ToolRequest): ToolResult {
val patchContent = request.parameters["patch"] as String
val parentDir = path.parent ?: Paths.get(".")
val fileName = path.fileName.toString()
val process = ProcessBuilder("patch", "-p1", fileName)
.directory(parentDir.toFile())
.redirectErrorStream(true)
.start()
return try {
process.outputStream.use { it.write(patchContent.toByteArray()) }
val output = process.inputStream.bufferedReader().use { it.readText() }
val exitCode = process.waitFor()
if (exitCode == 0) {
ToolResult.Success(
invocationId = request.invocationId,
output = "Patch applied successfully to $pathString: $output",
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Patch failed with exit code $exitCode: $output",
recoverable = false,
)
}
} finally {
process.destroy()
}
}
private fun handleExecutionException(
e: Throwable,
invocationId: ToolInvocationId,
pathString: String,
): ToolResult = when (e) {
is CancellationException -> throw e
is IOException -> ToolResult.Failure(
invocationId = invocationId,
reason = "IO error: ${e.message}",
recoverable = false,
)
is SecurityException -> ToolResult.Failure(
invocationId = invocationId,
reason = "Access denied: $pathString, ${e.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = invocationId,
reason = e.message ?: "Unknown error occurred",
recoverable = false,
)
}
}
@@ -0,0 +1,97 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
class FileReadTool(
allowedPaths: Set<Path> = emptySet(),
) : Tool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_read"
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override fun validateRequest(request: ToolRequest): ValidationResult =
(request.parameters["path"] as? String)?.let { pathString ->
runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
val isAllowed = normalizedAllowedPaths.isEmpty() || path in normalizedAllowedPaths
|| normalizedAllowedPaths.any { path.startsWith(it) }
ValidationResult.Valid.takeIf { isAllowed }
?: ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
}.getOrElse {
when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
else -> ValidationResult.Invalid(it.message ?: "Unknown error occured")
}
}
} ?: ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
takeIf { it is ValidationResult.Valid }?.let {
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
if (Files.exists(path)) {
readStringCatching(request, path, pathString)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
}
} ?: ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
recoverable = false,
)
}
}
private fun readStringCatching(request: ToolRequest, path: Path, pathString: String): ToolResult =
runCatching {
ToolResult.Success(
invocationId = request.invocationId,
output = Files.readString(path),
)
}.getOrElse {
when (it) {
is CancellationException -> throw it
is SecurityException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Access denied: $pathString, ${it.message}",
recoverable = false,
)
is IOException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "IO error: ${it.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred while reading file",
recoverable = false,
)
}
}
}
@@ -0,0 +1,172 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
class FileWriteTool(
allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_write"
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet()
return setOf(Paths.get(pathString).normalize().toAbsolutePath())
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val operation = request.parameters["operation"] as? String
val pathString = request.parameters["path"] as? String
return when {
operation == null ->
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'write' or 'delete'.")
pathString == null ->
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
else -> runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
checkPathAllowed(path, pathString, operation, request)
}.getOrElse { e ->
mapExceptionToValidationResult(e)
}
}
}
private fun checkPathAllowed(
path: Path,
pathString: String,
operation: String,
request: ToolRequest,
): ValidationResult {
return when {
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
!isPathAllowed(path) ->
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
operation == "write" && !request.parameters.containsKey("content") ->
ValidationResult.Invalid("Missing 'content' parameter for 'write' operation.")
operation != "write" && operation != "delete" ->
ValidationResult.Invalid("Unknown operation: $operation")
else -> ValidationResult.Valid
}
}
private fun isPathAllowed(path: Path): Boolean =
normalizedAllowedPaths.any { allowedPath ->
path.startsWith(allowedPath)
}
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
when (e) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid) {
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = validation.reason,
recoverable = false,
)
}
val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
runCatching {
performOperation(operation, path, pathString, request)
}.getOrElse { e ->
handleExecutionException(e, request.invocationId, pathString)
}
}
private fun performOperation(
operation: String,
path: Path,
pathString: String,
request: ToolRequest,
): ToolResult = when (operation) {
"write" -> {
val content = request.parameters["content"] as String
Files.writeString(path, content)
ToolResult.Success(
invocationId = request.invocationId,
output = "File written successfully to $pathString",
)
}
"delete" -> {
if (Files.exists(path)) {
Files.deleteIfExists(path)
ToolResult.Success(
invocationId = request.invocationId,
output = "File deleted successfully: $pathString",
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
}
}
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Unknown operation: $operation",
recoverable = false,
)
}
private fun handleExecutionException(
e: Throwable,
invocationId: ToolInvocationId,
pathString: String,
): ToolResult = when (e) {
is CancellationException -> throw e
is IOException -> ToolResult.Failure(
invocationId = invocationId,
reason = "IO error: ${e.message}",
recoverable = false,
)
is SecurityException -> ToolResult.Failure(
invocationId = invocationId,
reason = "Access denied: $pathString, ${e.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = invocationId,
reason = e.message ?: "Unknown error occurred",
recoverable = false,
)
}
}
@@ -0,0 +1,234 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
class FileEditToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(
parameters: Map<String, String>,
toolName: String = "file_edit",
): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolName,
parameters = parameters,
)
}
@Test
fun `execute patch success`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_patch")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "line1\nline2\nline3\n")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
// Patch to replace line2 with line2_modified
val patchContent = """
--- a/test.txt
+++ b/test.txt
@@ -2 +2 @@
-line2
+line2_modified
""".trimIndent() + "\n"
val request = createRequest(
mapOf(
"operation" to "patch",
"path" to filePath.toString(),
"patch" to patchContent,
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success, "Expected Success but got $result")
val content = Files.readString(filePath)
assertEquals("line1\nline2_modified\nline3\n", content)
}
@Test
fun `validateRequest returns Invalid for disallowed path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_test")
val otherDir = Files.createTempDirectory("other_dir")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "append",
"path" to otherDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list"))
}
@Test
fun `validateRequest returns Invalid for non-existent file`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_test")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "append",
"path" to tempDir.resolve("non_existent.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("File not found"))
}
@Test
fun `validateRequest returns Invalid for missing parameters`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_test")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "content")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
// Missing operation
val req1 = createRequest(mapOf("path" to filePath.toString()))
assertTrue(tool.validateRequest(req1) is ValidationResult.Invalid)
// Missing content for append
val req2 = createRequest(mapOf("operation" to "append", "path" to filePath.toString()))
assertTrue(tool.validateRequest(req2) is ValidationResult.Invalid)
// Missing target/replacement for replace
val req3 = createRequest(mapOf("operation" to "replace", "path" to filePath.toString(), "target" to "content"))
assertTrue(tool.validateRequest(req3) is ValidationResult.Invalid)
// Missing patch for patch
val req4 = createRequest(mapOf("operation" to "patch", "path" to filePath.toString()))
assertTrue(tool.validateRequest(req4) is ValidationResult.Invalid)
}
@Test
fun `execute append success`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_append")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "hello")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "append",
"path" to filePath.toString(),
"content" to " world",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
assertEquals("hello world", Files.readString(filePath))
}
@Test
fun `execute replace success`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "the quick brown fox")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "replace",
"path" to filePath.toString(),
"target" to "brown",
"replacement" to "red",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
assertEquals("the quick red fox", Files.readString(filePath))
}
@Test
fun `execute replace failure zero matches`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace_fail")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "the quick brown fox")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "replace",
"path" to filePath.toString(),
"target" to "green",
"replacement" to "red",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("not found"))
}
@Test
fun `execute replace failure multiple matches`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_replace_fail_multi")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "fox fox fox")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "replace",
"path" to filePath.toString(),
"target" to "fox",
"replacement" to "dog",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("found 3 times"))
}
@Test
fun `execute patch failure`() = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_patch_fail")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "original")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val patchContent = """
--- test.txt
+++ test.txt
@@ -1 +1 @@
-nonexistent
+something
""".trimIndent()
val request = createRequest(
mapOf(
"operation" to "patch",
"path" to filePath.toString(),
"patch" to patchContent,
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("Patch failed"))
}
}
@@ -0,0 +1,112 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.events.events.ToolRequest
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.util.UUID
class FileReadToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(path: String): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "file_read",
parameters = mapOf("path" to path),
)
}
@Test
fun `validateRequest returns Valid for allowed path`() = runBlocking {
val tool = FileReadTool(allowedPaths = emptySet())
val tempFile = Files.createTempFile("test_allowed", ".txt")
val request = createRequest(tempFile.toString())
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
@Test
fun `validateRequest returns Invalid for disallowed path`() = runBlocking {
val tempFile = Files.createTempFile("test_disallowed", ".txt")
val tool = FileReadTool(allowedPaths = setOf(tempFile.parent.resolve("subdir")))
val request = createRequest(tempFile.toString())
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("not in the allowed list"))
}
@Test
fun `validateRequest returns Invalid for missing path parameter`() = runBlocking {
val tool = FileReadTool()
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "file_read",
parameters = emptyMap(),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing or invalid 'path' parameter. Expected String.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `execute returns Success for valid file`() = runBlocking {
val content = "Hello, world!"
val tempFile = Files.createTempFile("test_content", ".txt")
Files.writeString(tempFile, content)
val tool = FileReadTool(allowedPaths = emptySet())
val request = createRequest(tempFile.toString())
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals(content, success.output)
assertEquals(invocationId, success.invocationId)
}
@Test
fun `execute returns Failure for non-existent file`() = runBlocking {
val tempDir = Files.createTempDirectory("test_dir")
val nonExistentFile = tempDir.resolve("missing.txt")
val tool = FileReadTool(allowedPaths = setOf(nonExistentFile))
val request = createRequest(nonExistentFile.toString())
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("File not found"))
assertFalse(failure.recoverable)
}
@Test
fun `execute returns Failure for disallowed path`() = runBlocking {
val tempFile = Files.createTempFile("test_disallowed_exec", ".txt")
val tool = FileReadTool(allowedPaths = setOf(tempFile.parent.resolve("other")))
val request = createRequest(tempFile.toString())
val result = tool.execute(request)
// It should fail at validation stage
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("is not in the allowed list"))
}
}
@@ -0,0 +1,238 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.events.events.ToolRequest
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
class FileWriteToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(
parameters: Map<String, String>,
toolName: String = "file_write",
): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolName,
parameters = parameters,
)
}
@Test
fun `validateRequest returns Valid for allowed path and valid operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"path" to tempDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
@Test
fun `validateRequest returns Invalid for disallowed path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val otherDir = Files.createTempDirectory("other_dir")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"path" to otherDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list"))
}
@Test
fun `validateRequest returns Invalid for missing operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"path" to tempDir.resolve("file.txt").toString(),
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing 'operation' parameter. Expected 'write' or 'delete'.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `validateRequest returns Invalid for missing path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"content" to "hello",
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Missing 'path' parameter. Expected String.", (result as ValidationResult.Invalid).reason)
}
@Test
fun `validateRequest returns Invalid for missing content on write`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "write",
"path" to tempDir.resolve("file.txt").toString(),
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Missing 'content' parameter for 'write' operation.", (result as ValidationResult.Invalid).reason)
}
@Test
fun `validateRequest returns Invalid for unknown operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "unknown",
"path" to tempDir.resolve("file.txt").toString(),
),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Unknown operation: unknown", (result as ValidationResult.Invalid).reason)
}
@Test
fun `execute returns Success for write operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = tempDir.resolve("test.txt")
val content = "Hello, FileWriteTool!"
val request = createRequest(
mapOf(
"operation" to "write",
"path" to filePath.toString(),
"content" to content,
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals("File written successfully to ${filePath}", success.output)
assertEquals(invocationId, success.invocationId)
assertEquals(content, Files.readString(filePath))
}
@Test
fun `execute returns Success for delete operation`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = tempDir.resolve("to_delete.txt")
Files.writeString(filePath, "content to delete")
val request = createRequest(
mapOf(
"operation" to "delete",
"path" to filePath.toString(),
),
)
println("TempDir: $tempDir")
println("FilePath: $filePath")
println("PathString from request: ${request.parameters["path"]}")
val result = tool.execute(request)
println("Result: $result")
println("Exists after: ${Files.exists(filePath)}")
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals("File deleted successfully: $filePath", success.output)
assertEquals(invocationId, success.invocationId)
assertFalse(Files.exists(filePath))
}
@Test
fun `execute returns Failure for deleting non-existent file`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = tempDir.resolve("non_existent.txt")
val request = createRequest(
mapOf(
"operation" to "delete",
"path" to filePath.toString(),
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertEquals("File not found: $filePath", failure.reason)
assertFalse(failure.recoverable)
}
@Test
fun `execute returns Failure for disallowed path`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test")
val otherDir = Files.createTempDirectory("other_dir")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val filePath = otherDir.resolve("illegal.txt")
val request = createRequest(
mapOf(
"operation" to "write",
"path" to filePath.toString(),
"content" to "illegal",
),
)
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("is not in the allowed list"))
}
@Test
fun `validateRequest returns Valid for existing file in allowed directory`() = runBlocking {
val tempDir = Files.createTempDirectory("file_write_test_existing")
val filePath = tempDir.resolve("existing.txt")
Files.writeString(filePath, "content")
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val request = createRequest(
mapOf(
"operation" to "delete",
"path" to filePath.toString(),
),
)
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
}