Merge remote-tracking branch 'origin/feat/session-robustness-and-dox' into feat/session-robustness-and-dox
This commit is contained in:
@@ -16,6 +16,7 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval
|
||||
- Web search and web fetch results are environment observations; they must be recorded as events by callers to preserve replay determinism (invariant #9).
|
||||
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
|
||||
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
|
||||
- Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
@@ -30,4 +31,4 @@ Standard adapter rules apply (see parent `AGENTS.md`). Network calls (web tools)
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
- `filesystem/` — filesystem read/write/list tools implementing `core:tools` contracts; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||
- `filesystem/` — filesystem tools implementing `core:tools` contracts: `FileReadTool`, `FileWriteTool` (write-only), `FileDeleteTool`, `FileEditTool`, list; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
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.ParamRole
|
||||
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 kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
/**
|
||||
* Deletes a file. Split out of [FileWriteTool] so deletion is an explicitly-named capability a model
|
||||
* must choose deliberately — it can never happen by getting a write-mode parameter wrong. Carries the
|
||||
* [ToolCapability.FILE_WRITE] capability (file mutation) and the same path jail as the writer.
|
||||
*/
|
||||
class FileDeleteTool(
|
||||
allowedPaths: Set<Path> = emptySet(),
|
||||
private val workingDir: Path? = null,
|
||||
) : Tool, FileAffectingTool, ToolExecutor {
|
||||
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_delete"
|
||||
override val description: String = "Delete the file at the specified relative path"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("path") {
|
||||
put("type", "string")
|
||||
put("description", "Relative path of the file to delete")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("path")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
||||
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
|
||||
|
||||
override fun affectedPaths(request: ToolRequest): Set<Path> {
|
||||
val pathString = request.parameters["path"] as? String ?: return emptySet()
|
||||
return setOf(resolvePath(pathString))
|
||||
}
|
||||
|
||||
private fun resolvePath(pathString: String): Path {
|
||||
val raw = Paths.get(pathString)
|
||||
return when {
|
||||
raw.isAbsolute -> raw.normalize()
|
||||
workingDir != null -> workingDir.resolve(raw).normalize()
|
||||
else -> raw.toAbsolutePath().normalize()
|
||||
}
|
||||
}
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val pathString = request.parameters["path"] as? String
|
||||
?: return ValidationResult.Invalid(
|
||||
"""Missing 'path' parameter (string). Call file_delete with {"path": "<relative path>"}.""",
|
||||
)
|
||||
return runCatching {
|
||||
val path = resolvePath(pathString)
|
||||
when {
|
||||
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
|
||||
!PathJail.isContained(path, normalizedAllowedPaths) ->
|
||||
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}.getOrElse { e -> mapExceptionToValidationResult(e) }
|
||||
}
|
||||
|
||||
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 pathString = request.parameters["path"] as String
|
||||
val path = resolvePath(pathString)
|
||||
runCatching {
|
||||
if (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,
|
||||
)
|
||||
}
|
||||
}.getOrElse { e -> handleExecutionException(e, request.invocationId, pathString) }
|
||||
}
|
||||
|
||||
private fun handleExecutionException(
|
||||
e: Throwable,
|
||||
invocationId: ToolInvocationId,
|
||||
pathString: String,
|
||||
): ToolResult = when (e) {
|
||||
is CancellationException -> throw e
|
||||
is IOException -> ToolResult.Failure(invocationId, "IO error: ${e.message}", recoverable = false)
|
||||
is SecurityException ->
|
||||
ToolResult.Failure(invocationId, "Access denied: $pathString, ${e.message}", recoverable = false)
|
||||
|
||||
else -> ToolResult.Failure(invocationId, e.message ?: "Unknown error occurred", recoverable = false)
|
||||
}
|
||||
}
|
||||
+29
-74
@@ -25,6 +25,12 @@ import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
/**
|
||||
* Writes content to a file. Write-only by design: deleting is the separate, explicitly-named
|
||||
* [FileDeleteTool] so a model can never delete a file by getting an `operation` mode wrong — a
|
||||
* destructive action must name itself. (Previously this tool carried an `operation: write|delete`
|
||||
* mode; splitting it removes the most-forgotten parameter and makes delete a distinct capability.)
|
||||
*/
|
||||
class FileWriteTool(
|
||||
allowedPaths: Set<Path> = emptySet(),
|
||||
private val workingDir: Path? = null,
|
||||
@@ -33,7 +39,7 @@ class FileWriteTool(
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_write"
|
||||
override val description: String = "Write content to a file at the specified path or delete the file entirely"
|
||||
override val description: String = "Write content to a file at the specified relative path (creates or overwrites)"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
@@ -43,11 +49,7 @@ class FileWriteTool(
|
||||
}
|
||||
putJsonObject("content") {
|
||||
put("type", "string")
|
||||
put("description", "File content")
|
||||
}
|
||||
putJsonObject("operation") {
|
||||
put("type", "string")
|
||||
put("description", "Either 'write' or 'delete'")
|
||||
put("description", "The full file content to write")
|
||||
}
|
||||
}
|
||||
put(
|
||||
@@ -55,7 +57,6 @@ class FileWriteTool(
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("path"))
|
||||
add(JsonPrimitive("content"))
|
||||
add(JsonPrimitive("operation"))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -78,45 +79,37 @@ class FileWriteTool(
|
||||
}
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val operation = request.parameters["operation"] as? String
|
||||
val pathString = request.parameters["path"] as? String
|
||||
val hasContent = request.parameters.containsKey("content")
|
||||
|
||||
return when {
|
||||
operation == null ->
|
||||
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'write' or 'delete'.")
|
||||
|
||||
pathString == null ->
|
||||
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
|
||||
ValidationResult.Invalid(
|
||||
"Missing 'path' parameter (string). Call file_write with " +
|
||||
"""{"path": "<relative path>", "content": "<full file content>"}.""",
|
||||
)
|
||||
|
||||
!hasContent ->
|
||||
ValidationResult.Invalid(
|
||||
"Missing 'content' parameter (string). Call file_write with " +
|
||||
"""{"path": "$pathString", "content": "<full file content>"}.""",
|
||||
)
|
||||
|
||||
else -> runCatching {
|
||||
val path = resolvePath(pathString)
|
||||
checkPathAllowed(path, pathString, operation, request)
|
||||
checkPathAllowed(path, pathString)
|
||||
}.getOrElse { e ->
|
||||
mapExceptionToValidationResult(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPathAllowed(
|
||||
path: Path,
|
||||
pathString: String,
|
||||
operation: String,
|
||||
request: ToolRequest,
|
||||
): ValidationResult {
|
||||
return when {
|
||||
private fun checkPathAllowed(path: Path, pathString: String): ValidationResult =
|
||||
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")
|
||||
|
||||
!isPathAllowed(path) -> ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPathAllowed(path: Path): Boolean =
|
||||
PathJail.isContained(path, normalizedAllowedPaths)
|
||||
@@ -139,13 +132,17 @@ class FileWriteTool(
|
||||
)
|
||||
}
|
||||
|
||||
val operation = request.parameters["operation"] as String
|
||||
val pathString = request.parameters["path"] as String
|
||||
val content = request.parameters["content"] as String
|
||||
val path = resolvePath(pathString)
|
||||
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
|
||||
|
||||
val result = runCatching {
|
||||
performOperation(operation, path, pathString, request)
|
||||
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "File written successfully to $pathString",
|
||||
)
|
||||
}.getOrElse { e ->
|
||||
handleExecutionException(e, request.invocationId, pathString)
|
||||
}
|
||||
@@ -165,48 +162,6 @@ class FileWriteTool(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOperation(
|
||||
operation: String,
|
||||
path: Path,
|
||||
pathString: String,
|
||||
request: ToolRequest,
|
||||
): ToolResult = when (operation) {
|
||||
"write" -> {
|
||||
val content = request.parameters["content"] as String
|
||||
withContext(Dispatchers.IO) {
|
||||
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "File written successfully to $pathString",
|
||||
)
|
||||
}
|
||||
|
||||
"delete" -> {
|
||||
if (Files.exists(path)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
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,
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.correx.infrastructure.tools.filesystem
|
||||
|
||||
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.nio.file.Files
|
||||
import java.util.*
|
||||
|
||||
class FileDeleteToolTest {
|
||||
|
||||
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
|
||||
private fun createRequest(parameters: Map<String, String>): ToolRequest = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = SessionId(UUID.randomUUID().toString()),
|
||||
stageId = StageId(UUID.randomUUID().toString()),
|
||||
toolName = "file_delete",
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Valid for existing file in allowed directory`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val filePath = tempDir.resolve("existing.txt")
|
||||
Files.writeString(filePath, "content")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(mapOf("path" to filePath.toString()))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing path with actionable message`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
val result = tool.validateRequest(createRequest(emptyMap()))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("Missing 'path'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for disallowed path`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val otherDir = Files.createTempDirectory("other_dir")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
val result = tool.validateRequest(createRequest(mapOf("path" to otherDir.resolve("x.txt").toString())))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute deletes an existing file`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val filePath = tempDir.resolve("to_delete.txt")
|
||||
Files.writeString(filePath, "content to delete")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
|
||||
val result = tool.execute(createRequest(mapOf("path" to filePath.toString())))
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
assertTrue((result as ToolResult.Success).output.startsWith("File deleted successfully: $filePath"))
|
||||
assertFalse(Files.exists(filePath))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure for deleting non-existent file`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val filePath = tempDir.resolve("non_existent.txt")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
|
||||
val result = tool.execute(createRequest(mapOf("path" to filePath.toString())))
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
assertEquals("File not found: $filePath", failure.reason)
|
||||
assertFalse(failure.recoverable)
|
||||
}
|
||||
}
|
||||
+27
-159
@@ -8,7 +8,6 @@ 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
|
||||
@@ -20,29 +19,20 @@ class FileWriteToolTest {
|
||||
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,
|
||||
)
|
||||
}
|
||||
private fun createRequest(parameters: Map<String, String>): ToolRequest = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = "file_write",
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Valid for allowed path and valid operation`(): Unit = runBlocking {
|
||||
fun `validateRequest returns Valid for allowed path with content`(): Unit = 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",
|
||||
),
|
||||
mapOf("path" to tempDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||
)
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
||||
}
|
||||
@@ -53,11 +43,7 @@ class FileWriteToolTest {
|
||||
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",
|
||||
),
|
||||
mapOf("path" to otherDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
@@ -65,171 +51,58 @@ class FileWriteToolTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing operation`(): Unit = runBlocking {
|
||||
fun `validateRequest returns Invalid for missing path with actionable message`(): Unit = 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)
|
||||
val result = tool.validateRequest(createRequest(mapOf("content" to "hello")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals(
|
||||
"Missing 'operation' parameter. Expected 'write' or 'delete'.",
|
||||
(result as ValidationResult.Invalid).reason,
|
||||
)
|
||||
val reason = (result as ValidationResult.Invalid).reason
|
||||
assertTrue(reason.contains("Missing 'path'"))
|
||||
// actionable: shows the required call shape
|
||||
assertTrue(reason.contains("\"content\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing path`(): Unit = runBlocking {
|
||||
fun `validateRequest returns Invalid for missing content with actionable message`(): Unit = 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 request = createRequest(mapOf("path" to tempDir.resolve("file.txt").toString()))
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals("Missing 'path' parameter. Expected String.", (result as ValidationResult.Invalid).reason)
|
||||
val reason = (result as ValidationResult.Invalid).reason
|
||||
assertTrue(reason.contains("Missing 'content'"))
|
||||
assertTrue(reason.contains("file_write"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing content on write`(): Unit = 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`(): Unit = 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`(): Unit = runBlocking {
|
||||
fun `execute writes content and attaches a diff`(): Unit = 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 request = createRequest(mapOf("path" to filePath.toString(), "content" to content))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val success = result as ToolResult.Success
|
||||
// Output starts with the status line; a unified diff of the change is appended (F-019).
|
||||
assertTrue(success.output.startsWith("File written successfully to $filePath"))
|
||||
assertTrue(success.metadata.containsKey("diff"))
|
||||
assertEquals(invocationId, success.invocationId)
|
||||
assertEquals(content, Files.readString(filePath))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Success for delete operation`(): Unit = 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(),
|
||||
),
|
||||
)
|
||||
|
||||
val result = tool.execute(request)
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val success = result as ToolResult.Success
|
||||
assertTrue(success.output.startsWith("File deleted successfully: $filePath"))
|
||||
assertEquals(invocationId, success.invocationId)
|
||||
assertFalse(Files.exists(filePath))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure for deleting non-existent file`(): Unit = 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`(): Unit = 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",
|
||||
),
|
||||
mapOf("path" to otherDir.resolve("illegal.txt").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`(): Unit = 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))
|
||||
assertTrue((result as ToolResult.Failure).reason.contains("is not in the allowed list"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -237,9 +110,7 @@ class FileWriteToolTest {
|
||||
val tempDir = Files.createTempDirectory("file_write_atomic")
|
||||
val target = tempDir.resolve("nested/deep/file.txt")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "hello"),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to target.toString(), "content" to "hello"))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
@@ -254,15 +125,12 @@ class FileWriteToolTest {
|
||||
val target = tempDir.resolve("file.txt")
|
||||
Files.writeString(target, "old")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "new"),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to target.toString(), "content" to "new"))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
assertEquals("new", Files.readString(target))
|
||||
// the staged temp file must not survive the swap
|
||||
val leftovers = Files.list(tempDir).use { stream ->
|
||||
stream.filter { it.fileName.toString().endsWith(".tmp") }.count()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.infrastructure.tools.filesystem.FileDeleteTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileEditTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileReadTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileWriteTool
|
||||
@@ -81,6 +82,14 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
||||
workingDir = fileWrite.workingDir,
|
||||
),
|
||||
)
|
||||
// file_delete is the explicit, separately-named deletion capability split out of
|
||||
// file_write; it shares the writer's path jail and is gated by the same fileWrite toggle.
|
||||
add(
|
||||
FileDeleteTool(
|
||||
allowedPaths = fileWrite.allowedPaths,
|
||||
workingDir = fileWrite.workingDir,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (fileEdit.enabled) {
|
||||
add(
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class SandboxedToolExecutorFileMutationTest {
|
||||
|
||||
private fun writeRequest(path: String, content: String) = ToolRequest(
|
||||
ToolInvocationId("inv"), SessionId("s"), StageId("st"), "file_write",
|
||||
mapOf("operation" to "write", "path" to path, "content" to content),
|
||||
mapOf("path" to path, "content" to content),
|
||||
)
|
||||
|
||||
private fun executor(tool: FileWriteTool, store: FakeArtifactStore, events: CapturingEventStore) =
|
||||
|
||||
Reference in New Issue
Block a user