Files

685 lines
26 KiB
Markdown

# Epic 11 — Infrastructure Layer
**date:** 2026-05-12
**scope:** `infrastructure/` — persistence, inference, tools, model management
**status:** ready to implement
---
## resume instructions
1. open project at `/home/kami/Programs/correx`
2. read this file top to bottom before writing any code
3. complete tasks in order — hard boundary after task 3 before tools work begins
4. do NOT explore beyond files listed in each task
5. in-memory stores and mocks are NOT acceptable — this epic is real implementations only
---
## prerequisite — `:core:tools` contract extension (before epic 11)
these three additions belong in `:core:tools`, not infrastructure. complete them first as a separate commit before touching any `infrastructure/` code.
### `ToolResult`
**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt`
```kotlin
sealed interface ToolResult {
data class Success(
val invocationId: ToolInvocationId,
val output: String,
val metadata: Map<String, String> = emptyMap(),
) : ToolResult
data class Failure(
val invocationId: ToolInvocationId,
val reason: String,
val recoverable: Boolean,
) : ToolResult
}
```
### `ToolExecutor`
**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt`
```kotlin
interface ToolExecutor {
suspend fun execute(request: ToolRequest): ToolResult
}
```
### `FileAffectingTool`
**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt`
marker interface for tools that touch the filesystem. sandbox uses this to know which paths to back up:
```kotlin
interface FileAffectingTool : Tool {
fun affectedPaths(request: ToolRequest): Set<Path>
}
```
---
## scope (what IS in epic 11)
- `SqliteEventStore` — already done, skip
- `LlamaCppInferenceProvider` — OpenAI-compatible REST client
- `LlamaCppTokenizer`
- `ModelManager` + `ManagedInferenceProvider` — load/unload/health/residency
- `ToolRegistry` — immutable after bootstrap
- `SandboxedToolExecutor``/tmp` isolation + backup/restore + approval gating
- `ShellTool` — argv-based, allowlist-gated
- `FilesystemTool` — path-allowlisted, traversal-safe
- `InfrastructureModule` — wiring
## scope (what is NOT in epic 11)
- postgres, snapshots, migrations
- ollama / vllm providers
- docker / network tools
- telemetry exporters
- process namespace isolation (seccomp/unshare) — parked
- scheduler / concurrency / backpressure
- CLI / API wiring
---
## dependency inventory
| module | key types | concrete import | file path |
|---|---|---|---|
| `:core:events` | `EventStore` | `import com.correx.core.events.stores.EventStore` | `core/events/src/main/kotlin/com/correx/core/events/stores/EventStore.kt` |
| `:core:events` | `NewEvent` | `import com.correx.core.events.events.NewEvent` | `core/events/src/main/kotlin/com/correx/core/events/events/EventEnvelope.kt` |
| `:core:events` | `EventMetadata` | `import com.correx.core.events.events.EventMetadata` | `core/events/src/main/kotlin/com/correx/core/events/events/EventMetadata.kt` |
| `:core:events` | `TypeId` | `import com.correx.core.utils.TypeId` | `core/events/src/main/kotlin/com/correx/core/utils/TypeId.kt` |
| `:core:inference` | `InferenceProvider` | `import com.correx.core.inference.InferenceProvider` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceProvider.kt` |
| `:core:inference` | `InferenceRequest` | `import com.correx.core.inference.InferenceRequest` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceRequest.kt` |
| `:core:inference` | `InferenceResponse` | `import com.correx.core.inference.InferenceResponse` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceResponse.kt` |
| `:core:inference` | `InferenceRouter` | `import com.correx.core.inference.InferenceRouter` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceRouter.kt` |
| `:core:inference` | `GenerationConfig` | `import com.correx.core.inference.GenerationConfig` | `core/inference/src/main/kotlin/com/correx/core/inference/GenerationConfig.kt` |
| `:core:inference` | `TokenUsage` | `import com.correx.core.inference.TokenUsage` | `core/events/src/main/kotlin/com/correx/core/inference/TokenUsage.kt` |
| `:core:inference` | `FinishReason` | `import com.correx.core.inference.FinishReason` | `core/inference/src/main/kotlin/com/correx/core/inference/FinishReason.kt` |
| `:core:inference` | `ProviderId` | `import com.correx.core.events.types.ProviderId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `:core:inference` | `ModelCapability` | `import com.correx.core.inference.ModelCapability` | `core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt` |
| `:core:inference` | `CapabilityScore` | `import com.correx.core.inference.CapabilityScore` | `core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt` |
| `:core:inference` | `ProviderHealth` | `import com.correx.core.inference.ProviderHealth` | `core/inference/src/main/kotlin/com/correx/core/inference/ProviderHealth.kt` |
| `:core:approvals` | `ApprovalEngine` | `import com.correx.core.approvals.domain.ApprovalEngine` | `core/approvals/src/main/kotlin/com/correx/core/approvals/domain/ApprovalEngine.kt` |
| `:core:approvals` | `ApprovalRequest` | `import com.correx.core.approvals.model.DomainApprovalRequest` | `core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt` |
| `:core:approvals` | `ApprovalContext` | `import com.correx.core.approvals.model.ApprovalContext` | `core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalContext.kt` |
| `:core:approvals` | `ApprovalMode` | `import com.correx.core.sessions.ApprovalMode` | `core/sessions/src/main/kotlin/com/correx/core/sessions/ApprovalMode.kt` |
| `:core:approvals` | `Tier` | `import com.correx.core.approvals.Tier` | `core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt` |
| `:core:tools` | `Tool` | `import com.correx.core.tools.contract.Tool` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt` |
| `:core:tools` | `ToolRequest` | `import com.correx.core.events.events.ToolRequest` | `core/events/src/main/kotlin/com/correx/core/events/events/ToolRequest.kt` |
| `:core:tools` | `ToolInvocationId` | `import com.correx.core.events.types.ToolInvocationId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `:core:tools` | `ToolResult` | `import com.correx.core.tools.contract.ToolResult` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt` |
| `:core:tools` | `ToolExecutor` | `import com.correx.core.tools.contract.ToolExecutor` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt` |
| `:core:tools` | `FileAffectingTool` | `import com.correx.core.tools.contract.FileAffectingTool` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt` |
| `:core:sessions` | `SessionId` | `import com.correx.core.events.types.SessionId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `:core:sessions` | `StageId` | `import com.correx.core.events.types.StageId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `infrastructure:persistence:sqlite` | `SqliteEventStore` | `import com.correx.infrastructure.persistence.sqlite.SqliteEventStore` | `infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt` |
---
## task 1 — `LlamaCppInferenceProvider`
**module:** `infrastructure:inference:llama_cpp`
**location:** `infrastructure/inference/llama_cpp/`
### what it does
calls llama-server's OpenAI-compatible REST API. implements `InferenceProvider`.
endpoints (constructed from `baseUrl = "http://127.0.0.1:10000"`):
- inference: `$baseUrl/v1/chat/completions`
- health: `$baseUrl/health`
- tokenize: `$baseUrl/tokenize`
### build deps
```groovy
implementation project(':core:inference')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
```
### request/response mapping
```kotlin
@Serializable
data class ChatCompletionRequest(
val model: String,
val messages: List<ChatMessage>,
val temperature: Double,
@SerialName("top_p") val topP: Double,
@SerialName("max_tokens") val maxTokens: Int,
@SerialName("stop") val stopSequences: List<String> = emptyList(),
val seed: Long? = null,
val stream: Boolean = false,
)
@Serializable
data class ChatMessage(val role: String, val content: String)
@Serializable
data class ChatCompletionResponse(
val id: String,
val choices: List<Choice>,
val usage: Usage,
)
@Serializable
data class Choice(
val message: ChatMessage,
@SerialName("finish_reason") val finishReason: String,
)
@Serializable
data class Usage(
@SerialName("prompt_tokens") val promptTokens: Int,
@SerialName("completion_tokens") val completionTokens: Int,
@SerialName("total_tokens") val totalTokens: Int,
)
```
### context pack → messages mapping
- L0 (system) → `role = "system"`
- L1 (task) → `role = "user"`
- L2 (history/tools) → `role = "assistant"` or `role = "user"` depending on `sourceType`
- if `ContextPack` is empty → single `user` message with empty string (do not crash)
### `LlamaCppInferenceProvider`
```kotlin
class LlamaCppInferenceProvider(
private val modelId: String,
private val baseUrl: String = "http://127.0.0.1:10000",
private val httpClient: HttpClient = defaultHttpClient(),
) : InferenceProvider {
override val id: ProviderId = ProviderId("llama-cpp:$modelId")
override val tokenizer: Tokenizer = LlamaCppTokenizer(baseUrl, httpClient)
override suspend fun infer(request: InferenceRequest): InferenceResponse
override fun healthCheck(): ProviderHealth
override fun capabilities(): Set<CapabilityScore>
}
```
### `healthCheck()`
GET `$baseUrl/health`. 200 → `ProviderHealth.Healthy`, otherwise `ProviderHealth.Unhealthy`.
### `capabilities()`
hardcoded, pattern-matched on `modelId`:
- contains "coder" → `ModelCapability.CODING` score 0.9
- contains "r1" or "deepseek" → `ModelCapability.REASONING` score 0.9
- default → `ModelCapability.GENERAL` score 0.7
### constraints
- `stream = false` always
- do NOT implement retry logic — `RetryCoordinator`'s responsibility
- do NOT catch `CancellationException` — let it propagate
- throw `InferenceProviderException` on non-2xx HTTP responses
- `latencyMs` = wall clock time of the HTTP call
---
## task 2 — `LlamaCppTokenizer`
**module:** `infrastructure:inference:llama_cpp`
```kotlin
class LlamaCppTokenizer(
private val baseUrl: String,
private val httpClient: HttpClient,
) : Tokenizer {
override fun tokenize(text: String): List<Int>
override fun count(text: String): Int = tokenize(text).size
}
```
POST `$baseUrl/tokenize` with `{"content": text}`, parse `{"tokens": [...]}`.
fallback: if endpoint unavailable → approximate `text.length / 4`, log warning, do NOT throw.
---
## task 3 — `ModelManager` + `ManagedInferenceProvider`
**module:** `infrastructure:inference:llama_cpp`
manages llama-server process lifecycle. single model at a time, enforced by `Mutex`.
### residency modes
```kotlin
enum class ResidencyMode {
PERSISTENT, // never unload
DYNAMIC, // unload after idle timeout
EPHEMERAL, // unload immediately after infer completes
}
```
### `ModelDescriptor`
```kotlin
data class ModelDescriptor(
val modelId: String,
val modelPath: String,
val residencyMode: ResidencyMode,
val idleTimeoutMs: Long = 60_000L,
val contextSize: Int = 8192,
)
```
### `ModelManager` interface
```kotlin
interface ModelManager {
suspend fun load(descriptor: ModelDescriptor): InferenceProvider
suspend fun unload(modelId: String)
fun currentModel(): ModelDescriptor?
fun healthCheck(): ProviderHealth
}
```
### `DefaultModelManager`
model swap via **process restart** — kill existing llama-server, spawn new with `--model` flag.
```kotlin
class DefaultModelManager(
private val llamaServerBin: String = "llama-server",
private val host: String = "127.0.0.1",
private val port: Int = 10000,
private val healthTimeoutMs: Long = 30_000L,
private val eventStore: EventStore,
) : ModelManager
```
**`load()` flow — event appended AFTER committed state:**
1. acquire `Mutex`
2. if `currentDescriptor?.modelId == descriptor.modelId` → no-op, return existing provider
3. if process running: `process.destroyForcibly()`, wait for exit, clear state, append `ModelUnloadedEvent`
4. spawn: `ProcessBuilder(llamaServerBin, "--model", descriptor.modelPath, "--ctx-size", descriptor.contextSize.toString(), "--host", host, "--port", port.toString())`
5. redirect stdout/stderr to `~/.local/share/correx/logs/llama-server.log` — create dir if absent
6. poll GET `http://$host:$port/health` until 200 — if `healthTimeoutMs` exceeded: throw `ModelLoadException` (no event emitted on failure)
7. set `currentProcess`, `currentDescriptor`
8. append `ModelLoadedEvent` ← only after process is healthy and state committed
9. release `Mutex`, return `ManagedInferenceProvider(delegate, this, descriptor)`
**`unload()` flow — event appended AFTER committed state:**
1. acquire `Mutex`
2. `process.destroyForcibly()`, wait for exit
3. clear `currentProcess`, `currentDescriptor`
4. append `ModelUnloadedEvent` ← only after process is dead and state cleared
5. release `Mutex`
### `ManagedInferenceProvider`
wraps `LlamaCppInferenceProvider`, notifies manager for residency policy:
```kotlin
class ManagedInferenceProvider(
private val delegate: InferenceProvider,
private val manager: DefaultModelManager,
private val descriptor: ModelDescriptor,
) : InferenceProvider by delegate {
override suspend fun infer(request: InferenceRequest): InferenceResponse {
manager.touch(descriptor.modelId) // reset DYNAMIC idle timer
val response = delegate.infer(request)
manager.onInferenceCompleted(descriptor) // trigger EPHEMERAL unload if applicable
return response
}
}
```
`DefaultModelManager` internal methods (not on `ModelManager` interface):
- `touch(modelId)` — resets DYNAMIC idle timer coroutine
- `onInferenceCompleted(descriptor)` — calls `unload()` immediately if `EPHEMERAL`, no-op otherwise
**DYNAMIC timer behaviour:**
- timer coroutine starts on first `touch()` after load
- each `touch()` cancels and restarts the timer
- on timeout: calls `unload()`
### constraints
- do NOT manage GPU memory directly — llama-server handles that
- do NOT emit events before process state is committed — event log must reflect truth, not intent
- `ModelLoadException` thrown on health timeout — no event emitted in this case
- spawned process log dir created on first use if absent
---
## task 4 — `ToolRegistry`
**module:** `infrastructure:tools`
immutable after construction:
```kotlin
class ToolRegistry private constructor(
private val tools: Map<String, Tool>,
) {
fun resolve(name: String): Tool? = tools[name]
fun all(): List<Tool> = tools.values.toList()
companion object {
fun build(vararg tools: Tool): ToolRegistry =
ToolRegistry(tools.associateBy { it.name })
fun build(tools: List<Tool>): ToolRegistry =
ToolRegistry(tools.associateBy { it.name })
}
}
```
no `register()` method — `private constructor` enforces bootstrap-only population.
---
## task 5 — `SandboxedToolExecutor`
**module:** `infrastructure:tools`
```kotlin
class SandboxedToolExecutor(
private val delegate: ToolExecutor,
private val registry: ToolRegistry,
private val approvalEngine: ApprovalEngine,
private val eventStore: EventStore,
private val workDir: Path = Path("/tmp/correx-sandbox"),
) : ToolExecutor {
override suspend fun execute(request: ToolRequest): ToolResult
}
```
execution flow:
1. resolve tool from `registry` by `request.toolName` — if not found: return `ToolResult.Failure(recoverable = false)`
2. check approval via `tool.tier`:
```kotlin
when (tool.tier) {
Tier.T0, Tier.T1 -> { /* proceed */ }
Tier.T2, Tier.T3 -> { /* evaluate approval */ }
}
```
if not approved: emit `ToolExecutionRejectedEvent`, return `ToolResult.Failure(recoverable = false)`
3. emit `ToolExecutionStartedEvent`
4. create working dir: `/tmp/correx-sandbox/{sessionId}/{invocationId}/`
5. if tool implements `FileAffectingTool`: call `tool.affectedPaths(request)`, copy each to `{workDir}/{filename}.bak` via `Files.copy`
6. if backup fails: return `ToolResult.Failure` before delegating — do NOT proceed
7. delegate to underlying `ToolExecutor`
8. on `ToolResult.Success`: `Files.move` result to original path, delete `.bak` files, emit `ToolExecutionCompletedEvent`
9. on `ToolResult.Failure`: restore `.bak` files via `Files.move`, delete working dir, emit `ToolExecutionFailedEvent`
### constraints
- working dir is fresh per invocation — no shared state
- `.bak` files always cleaned up — no leaks regardless of outcome
- do NOT use `File.deleteOnExit()` — explicit cleanup only
- backup/restore uses `Files.copy` + `Files.move` — not shell commands
---
## task 6 — `ShellTool`
**module:** `infrastructure:tools:shell`
uses `argv: List<String>` — no shell string parsing, no shell invocation.
```kotlin
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
override suspend fun execute(request: ToolRequest): ToolResult
}
```
`execute()`:
- extract `argv: List<String>` from `request.parameters["argv"]`
- first element is the executable — validate against `allowedExecutables`
- if not in allowlist: return `ToolResult.Failure(recoverable = false)` immediately
- spawn: `ProcessBuilder(argv).apply { environment().clear() }`
- capture stdout + stderr separately
- enforce `timeoutMs` — `process.destroyForcibly()` on timeout, return `ToolResult.Failure`
- exit code 0 → `ToolResult.Success(output = stdout)`
- non-zero → `ToolResult.Failure(reason = stderr, recoverable = false)`
### constraints
- do NOT invoke `bash -c` or any shell — `ProcessBuilder(argv)` directly
- do NOT use `Runtime.exec()`
- do NOT inherit parent process environment — `environment().clear()` explicitly
- stdout and stderr captured, never forwarded to parent stdout
---
## task 7a — `FileReadTool`
**file:** `infrastructure/tools/filesystem/FileReadTool.kt`
**module:** `infrastructure:tools:filesystem`
```kotlin
class FileReadTool(
private val allowedPaths: Set<Path> = emptySet(),
) : Tool {
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
}
```
also implements `ToolExecutor` directly.
supported operations (from `request.parameters["operation"]`):
- `read` — read file contents, return as `output`
- `list` — list directory contents at `path`
- `exists` — return `"true"` or `"false"`
does NOT implement `FileAffectingTool` — read operations require no backup.
path validation before ANY operation:
1. extract `path` from `request.parameters["path"]`
2. resolve to absolute via `toRealPath()`
3. check resolved path starts with at least one `allowedPaths` root
4. if not: return `ToolResult.Failure(recoverable = false)`
### constraints
- `allowedPaths` empty = deny ALL — fail-secure default
- `toRealPath()` resolves symlinks — re-validate after resolution
- do NOT follow symlinks outside allowed paths
- T1 — goes through `SandboxedToolExecutor` approval check but auto-approved
---
## task 7b — `FileWriteTool`
**file:** `infrastructure/tools/filesystem/FileWriteTool.kt`
**module:** `infrastructure:tools:filesystem`
```kotlin
class FileWriteTool(
private val allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool {
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>
override fun validateRequest(request: ToolRequest): ValidationResult
}
```
also implements `ToolExecutor`.
supported operations:
- `write` — create or fully overwrite file at `path` with `content` parameter
- `delete` — delete file at `path`
`affectedPaths()` — extract `path` from request, resolve absolute, return as singleton set.
path validation — same as `FileReadTool` but applied to write targets.
### constraints
- `allowedPaths` empty = deny ALL
- `toRealPath()` on parent dir for new files — target file may not exist yet, validate parent
- T2 — requires approval via `SandboxedToolExecutor`
- backup/restore handled by `SandboxedToolExecutor` via `FileAffectingTool` — do NOT implement backup here
---
## task 7c — `FileEditTool`
**file:** `infrastructure/tools/filesystem/FileEditTool.kt`
**module:** `infrastructure:tools:filesystem`
```kotlin
class FileEditTool(
private val allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool {
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>
override fun validateRequest(request: ToolRequest): ValidationResult
}
```
also implements `ToolExecutor`.
supported operations (from `request.parameters["operation"]`):
- `append` — append `content` to end of existing file
- `replace` — find `target` string in file, replace with `replacement` — fails if `target` not found or matches multiple times
- `patch` — apply a unified diff from `patch` parameter via `ProcessBuilder("patch", "-p0")` — T3 because patch application is the hardest to verify
`affectedPaths()` — same as `FileWriteTool`, returns target path as singleton.
path validation — same pattern, file must exist for all operations.
### constraints
- `allowedPaths` empty = deny ALL
- `replace` must match exactly once — if zero or multiple matches: `ToolResult.Failure(recoverable = false)`
- `patch` invokes external `patch` binary via `ProcessBuilder` — do NOT use shell
- T3 — highest approval tier for file tools
- backup/restore handled by `SandboxedToolExecutor` — always backs up before any edit
---
## task 8 — integration wiring
**module:** `infrastructure`
```kotlin
object InfrastructureModule {
fun createEventStore(dbPath: String): EventStore =
SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))
fun createModelManager(eventStore: EventStore): ModelManager =
DefaultModelManager(eventStore = eventStore)
fun createToolRegistry(config: ToolConfig): ToolRegistry =
ToolRegistry.build(buildList {
if (config.shell.enabled) add(ShellTool(config.shell.allowedExecutables))
if (config.fileRead.enabled) add(FileReadTool(config.fileRead.allowedPaths))
if (config.fileWrite.enabled) add(FileWriteTool(config.fileWrite.allowedPaths))
if (config.fileEdit.enabled) add(FileEditTool(config.fileEdit.allowedPaths))
})
fun createToolExecutor(
registry: ToolRegistry,
approvalEngine: ApprovalEngine,
eventStore: EventStore,
): ToolExecutor = SandboxedToolExecutor(
delegate = DispatchingToolExecutor(registry),
registry = registry,
approvalEngine = approvalEngine,
eventStore = eventStore,
)
}
```
`DispatchingToolExecutor` — resolves tool from registry by `request.toolName`, delegates. returns `ToolResult.Failure` if not found.
`ToolConfig` stub until epic 12:
```kotlin
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())
```
---
## minimum test targets
### `LlamaCppInferenceProviderTest`
- request maps `GenerationConfig` fields correctly to `ChatCompletionRequest`
- usage mapped to `TokenUsage` correctly
- non-2xx response → `InferenceProviderException` thrown
- `healthCheck()` returns `Healthy` on 200, `Unhealthy` otherwise
### `DefaultModelManagerTest`
- `load()` same `modelId` twice → no-op, no process restart
- `load()` different `modelId` → kills current process, starts new
- health check timeout → `ModelLoadException`, no `ModelLoadedEvent` emitted
- `ModelLoadedEvent` emitted only after health check passes
- `ModelUnloadedEvent` emitted only after process is dead
### `ManagedInferenceProviderTest`
- `EPHEMERAL`: `unload()` called after `infer()` completes
- `DYNAMIC`: idle timer resets on each `infer()` call
- `PERSISTENT`: `unload()` never called automatically
### `ShellToolTest`
- empty `allowedExecutables` → all commands denied
- executable not in allowlist → `Failure` before process spawn
- timeout → process killed, `Failure` returned
- non-zero exit → `Failure` with stderr as reason
- environment is cleared — no parent env inheritance
### `FilesystemToolTest`
- empty `allowedPaths` → all paths denied
- path traversal `../` → blocked after `toRealPath()` resolution
- symlink escape → blocked after re-validation
- `affectedPaths()` returns empty set for `read`/`list`/`exists`
### `SandboxedToolExecutorTest`
- approval rejected → `ToolExecutionRejectedEvent` emitted, delegate not called
- delegate returns `Failure` → `.bak` restored, working dir deleted
- backup creation fails → `Failure` before delegate call
- `.bak` files cleaned up on success
---
## explicitly out of scope
- postgres
- ollama / vllm
- docker tools
- network tools
- telemetry exporters
- process namespace isolation
- CLI / API wiring (epic 12)
- config file loading (epic 12)