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,19 @@
package com.correx.infrastructure.tools
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.registry.ToolRegistry
class DefaultToolRegistry private constructor(
private val tools: Map<String, Tool>,
) : ToolRegistry {
override fun resolve(name: String): Tool? = tools[name]
override fun all(): List<Tool> = tools.values.toList()
companion object {
fun build(vararg tools: Tool): ToolRegistry =
DefaultToolRegistry(tools.associateBy { it.name })
fun build(tools: List<Tool>): ToolRegistry =
DefaultToolRegistry(tools.associateBy { it.name })
}
}
@@ -0,0 +1,26 @@
package com.correx.infrastructure.tools
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
class DispatchingToolExecutor(
private val registry: ToolRegistry
) : ToolExecutor {
override suspend fun execute(request: ToolRequest): ToolResult {
val tool = registry.resolve(request.toolName)
?: return ToolResult.Failure(
invocationId = request.invocationId,
reason = "tool not found: ${request.toolName}",
recoverable = false
)
return (tool as? ToolExecutor)?.execute(request)
?: ToolResult.Failure(
invocationId = request.invocationId,
reason = "tool ${request.toolName} does not support execution",
recoverable = false
)
}
}
@@ -0,0 +1,267 @@
package com.correx.infrastructure.tools
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.ApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolExecutionStartedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.sessions.ApprovalMode
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.*
@SuppressWarnings("TooManyFunctions", "LongParameterList")
class SandboxedToolExecutor(
private val delegate: ToolExecutor,
private val registry: ToolRegistry,
private val approvalEngine: ApprovalEngine,
private val eventStore: EventStore,
private val eventDispatcher: EventDispatcher,
private val workDir: Path = Path.of("/tmp/correx-sandbox"),
) : ToolExecutor {
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val sessionId = request.sessionId
val invocationId = request.invocationId
val toolName = request.toolName
val startTime = System.currentTimeMillis()
// 1. resolve tool
val tool = registry.resolve(toolName)
?: return@withContext ToolResult.Failure(
invocationId = invocationId,
reason = "tool not found: $toolName",
recoverable = false,
)
// 2. approval check for T2-T4
val requiresApproval = when (tool.tier) {
Tier.T0, Tier.T1 -> false
Tier.T2, Tier.T3, Tier.T4 -> true
}
if (requiresApproval) {
val sessionEvents = eventStore.read(sessionId)
val decision = evaluateApproval(tool, sessionId, request.stageId, sessionEvents)
if (!decision.isApproved) {
emitRejected(sessionId, invocationId, toolName, tool.tier, decision.reason ?: "approval denied")
return@withContext ToolResult.Failure(
invocationId = invocationId,
reason = decision.reason ?: "approval denied",
recoverable = false,
)
}
}
// 3. emit started
emitStarted(sessionId, invocationId, toolName)
// 4. create working dir
val workingDir = workDir.resolve(sessionId.value).resolve(invocationId.value)
Files.createDirectories(workingDir)
// 5. compute affected paths once (A3: avoid double-invocation divergence)
val affectedPaths: Set<Path> = if (tool is FileAffectingTool) tool.affectedPaths(request) else emptySet()
// 6. backup affected files
val backupMap: Map<Path, Path> = try {
backupAffectedFiles(affectedPaths, workingDir)
} catch (e: IOException) {
return@withContext ToolResult.Failure(
invocationId = invocationId,
reason = "backup failed: ${e.message}",
recoverable = false,
)
}
// 7. delegate
val durationMs = { System.currentTimeMillis() - startTime }
when (val result = delegate.execute(request)) {
is ToolResult.Success -> {
restoreOrClean(backupMap, success = true)
cleanWorkingDir(workingDir)
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs())
result
}
is ToolResult.Failure -> {
restoreOrClean(backupMap, success = false)
cleanWorkingDir(workingDir)
emitFailed(sessionId, invocationId, toolName, result.reason)
result
}
}
}
// --- approval ---
private fun evaluateApproval(
tool: Tool,
sessionId: SessionId,
stageId: StageId,
sessionEvents: List<StoredEvent>,
) = approvalEngine.evaluate(
request = DomainApprovalRequest(
id = ApprovalRequestId(UUID.randomUUID().toString()),
tier = tool.tier,
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
riskSummaryId = null,
timestamp = Clock.System.now(),
causationId = null,
correlationId = null,
),
context = ApprovalContext(
identity = ApprovalScopeIdentity(sessionId, stageId, null),
mode = ApprovalMode.PROMPT,
),
grants = extractGrants(sessionEvents),
now = Clock.System.now(),
)
private fun extractGrants(events: List<StoredEvent>): List<ApprovalGrant> =
events
.mapNotNull { it.payload as? ApprovalGrantCreatedEvent }
.map { e ->
ApprovalGrant(
id = e.grantId,
scope = e.scope,
permittedTiers = e.permittedTiers,
reason = e.reason,
timestamp = Clock.System.now(),
expiresAt = e.expiresAt,
)
}
// --- backup/restore ---
/**
* Returns a map of originalPath -> backupPath for all affected files.
* Throws IOException if any backup fails — caller handles this.
*/
private fun backupAffectedFiles(
affectedPaths: Collection<Path>,
workingDir: Path,
): Map<Path, Path> = buildMap {
for (original in affectedPaths) {
// A1: skip files that don't yet exist (new-file tools have nothing to back up)
if (!Files.exists(original)) continue
val backupPath = workingDir.resolve("${original.fileName}.bak")
Files.copy(original, backupPath, StandardCopyOption.REPLACE_EXISTING)
put(original, backupPath)
}
}
/**
* On success: delete backups.
* On failure: restore originals from backups.
*/
private fun restoreOrClean(backupMap: Map<Path, Path>, success: Boolean) {
for ((original, backup) in backupMap) {
try {
if (success) {
Files.deleteIfExists(backup)
} else {
Files.move(backup, original, StandardCopyOption.REPLACE_EXISTING)
}
} catch (_: IOException) {
// best effort
}
}
}
private fun cleanWorkingDir(workingDir: Path) {
try {
Files.walk(workingDir)
.sorted(Comparator.reverseOrder())
.forEach { Files.deleteIfExists(it) }
} catch (_: IOException) {
// best effort
}
}
// --- event emission ---
private suspend fun emitRejected(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
tier: Tier,
reason: String,
) = emit(sessionId, ToolExecutionRejectedEvent(invocationId, sessionId, toolName, tier, reason))
private suspend fun emitStarted(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName))
private suspend fun emitCompleted(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
result: ToolResult.Success,
tool: Tool,
affectedPaths: Set<Path>,
durationMs: Long,
) {
val affectedEntities = affectedPaths.map { it.toString() }
emit(
sessionId,
ToolExecutionCompletedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolName,
receipt = ToolReceipt(
invocationId = invocationId,
toolName = toolName,
exitCode = result.exitCode,
outputSummary = result.output,
structuredOutput = result.metadata,
affectedEntities = affectedEntities,
durationMs = durationMs,
tier = tool.tier,
timestamp = Clock.System.now(),
),
),
)
}
private suspend fun emitFailed(
sessionId: SessionId,
invocationId: ToolInvocationId,
toolName: String,
reason: String,
) = emit(sessionId, ToolExecutionFailedEvent(invocationId, sessionId, toolName, reason))
private suspend fun emit(sessionId: SessionId, payload: EventPayload) {
eventDispatcher.emit(payload, sessionId)
}
}
@@ -0,0 +1,46 @@
package com.correx.infrastructure.tools
import com.correx.core.tools.contract.Tool
import com.correx.infrastructure.tools.filesystem.FileEditTool
import com.correx.infrastructure.tools.filesystem.FileReadTool
import com.correx.infrastructure.tools.filesystem.FileWriteTool
import com.correx.infrastructure.tools.shell.ShellTool
import java.nio.file.Path
data class ToolConfig(
val shell: ShellConfig = ShellConfig(),
val fileRead: FileReadConfig = FileReadConfig(),
val fileWrite: FileWriteConfig = FileWriteConfig(),
val fileEdit: FileEditConfig = FileEditConfig(),
)
data class FileReadConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class FileWriteConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet())
/**
* Extension function to convert [ToolConfig] into a list of [Tool] implementations.
*/
fun ToolConfig.buildTools(): List<Tool> = buildList {
if (shell.enabled) {
add(ShellTool(
allowedExecutables = shell.allowedExecutables
))
}
if (fileRead.enabled) {
add(FileReadTool(
allowedPaths = fileRead.allowedPaths
))
}
if (fileWrite.enabled) {
add(FileWriteTool(
allowedPaths = fileWrite.allowedPaths
))
}
if (fileEdit.enabled) {
add(FileEditTool(
allowedPaths = fileEdit.allowedPaths
))
}
}
@@ -0,0 +1,93 @@
package com.correx.infrastructure.tools.shell
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
class ShellTool(
private val allowedExecutables: Set<String> = emptySet(),
private val timeoutMs: Long = 30_000L,
) : Tool, ToolExecutor {
override val name: String = "shell"
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> =
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
override fun validateRequest(request: ToolRequest): ValidationResult {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
return when {
argv.isNullOrEmpty() ->
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
else -> ValidationResult.Valid
}
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
takeIf { this is ValidationResult.Valid }?.let {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
val process = ProcessBuilder(argv).apply {
environment().clear()
}.start()
runCatching {
runCmd(request, process)
}.getOrElse {
process.destroyForcibly()
if (it is TimeoutCancellationException) {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process timed out after ${timeoutMs}ms",
recoverable = false,
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred during execution",
recoverable = false,
)
}
}
} ?: ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
recoverable = false,
)
}
}
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
val exitCode = process.waitFor()
val stdout = stdoutDeferred.await()
val stderr = stderrDeferred.await()
val metadata = if (stderr.isNotBlank()) mapOf("stderr" to stderr) else emptyMap()
if (exitCode != 0) {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process exited with code $exitCode${if (stderr.isNotBlank()) ": $stderr" else ""}",
recoverable = false,
)
} else {
ToolResult.Success(
invocationId = request.invocationId,
output = stdout,
exitCode = exitCode,
metadata = metadata,
)
}
}
}
@@ -0,0 +1,115 @@
package com.correx.infrastructure.tools.shell
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.UUID
class ShellToolTest {
private val sessionId = SessionId(UUID.randomUUID().toString())
private val stageId = StageId(UUID.randomUUID().toString())
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun createRequest(argv: List<String>): ToolRequest {
return ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "shell",
parameters = mapOf("argv" to argv),
)
}
@Test
fun `validateRequest returns Valid for allowed executable`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(listOf("echo", "hello"))
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
}
@Test
fun `validateRequest returns Invalid for disallowed executable`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(listOf("ls"))
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals("Executable 'ls' is not in the allowed list.", (result as ValidationResult.Invalid).reason)
}
@Test
fun `validateRequest returns Invalid for empty argv`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(emptyList())
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing or empty 'argv' parameter. Expected List<String>.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `validateRequest returns Invalid for null argv`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = "shell",
parameters = emptyMap(),
)
val result = tool.validateRequest(request)
assertTrue(result is ValidationResult.Invalid)
assertEquals(
"Missing or empty 'argv' parameter. Expected List<String>.",
(result as ValidationResult.Invalid).reason,
)
}
@Test
fun `execute returns Success for exit code 0`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo"))
val request = createRequest(listOf("echo", "hello"))
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertTrue(success.output.startsWith("hello"))
assertEquals(0, success.exitCode)
assertEquals(invocationId, success.invocationId)
}
@Test
fun `execute returns Failure with non-zero exit code for failed process`() = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("false"))
val request = createRequest(listOf("false"))
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("exited with code 1"))
assertEquals(invocationId, failure.invocationId)
}
@Test
fun `execute returns Failure on timeout`() = runBlocking {
// Using sleep command to simulate timeout
val tool = ShellTool(allowedExecutables = setOf("sleep"), timeoutMs = 100L)
val request = createRequest(listOf("sleep", "1"))
val result = tool.execute(request)
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertEquals("Process timed out after 100ms", failure.reason)
assertFalse(failure.recoverable)
}
}