Merge remote-tracking branch 'origin/feat/session-robustness-and-dox' into feat/session-robustness-and-dox

This commit is contained in:
2026-06-29 20:58:07 +04:00
29 changed files with 1047 additions and 272 deletions
+2 -1
View File
@@ -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)
@@ -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)
}
}
@@ -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,
@@ -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)
}
}
@@ -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(
@@ -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) =